feat: projects with context - schema, API, frontend page, task assignment (HQ-17, HQ-21)
This commit is contained in:
@@ -38,6 +38,29 @@ export interface ProgressNote {
|
||||
note: string;
|
||||
}
|
||||
|
||||
// ─── Projects ───
|
||||
|
||||
export interface ProjectLink {
|
||||
label: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export const projects = pgTable("projects", {
|
||||
id: uuid("id").defaultRandom().primaryKey(),
|
||||
name: text("name").notNull(),
|
||||
description: text("description"),
|
||||
context: text("context"), // Architecture notes, how-to, credentials references
|
||||
repos: jsonb("repos").$type<string[]>().default([]), // Git repo URLs
|
||||
links: jsonb("links").$type<ProjectLink[]>().default([]), // Related URLs (docs, domains, dashboards)
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
|
||||
});
|
||||
|
||||
export type Project = typeof projects.$inferSelect;
|
||||
export type NewProject = typeof projects.$inferInsert;
|
||||
|
||||
// ─── Tasks ───
|
||||
|
||||
export const tasks = pgTable("tasks", {
|
||||
id: uuid("id").defaultRandom().primaryKey(),
|
||||
taskNumber: integer("task_number"),
|
||||
@@ -49,6 +72,7 @@ export const tasks = pgTable("tasks", {
|
||||
position: integer("position").notNull().default(0),
|
||||
assigneeId: text("assignee_id"),
|
||||
assigneeName: text("assignee_name"),
|
||||
projectId: uuid("project_id").references(() => projects.id, { onDelete: "set null" }),
|
||||
progressNotes: jsonb("progress_notes").$type<ProgressNote[]>().default([]),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Elysia } from "elysia";
|
||||
import { cors } from "@elysiajs/cors";
|
||||
import { taskRoutes } from "./routes/tasks";
|
||||
import { adminRoutes } from "./routes/admin";
|
||||
import { projectRoutes } from "./routes/projects";
|
||||
import { auth } from "./lib/auth";
|
||||
import { db } from "./db";
|
||||
import { tasks, users } from "./db/schema";
|
||||
@@ -113,6 +114,7 @@ const app = new Elysia()
|
||||
})
|
||||
|
||||
.use(taskRoutes)
|
||||
.use(projectRoutes)
|
||||
.use(adminRoutes)
|
||||
|
||||
// Current user info (role, etc.)
|
||||
|
||||
153
backend/src/routes/projects.ts
Normal file
153
backend/src/routes/projects.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
import { Elysia, t } from "elysia";
|
||||
import { db } from "../db";
|
||||
import { projects, tasks } from "../db/schema";
|
||||
import { eq, asc, desc } from "drizzle-orm";
|
||||
import { auth } from "../lib/auth";
|
||||
|
||||
const BEARER_TOKEN = process.env.API_BEARER_TOKEN || "hammer-dev-token";
|
||||
|
||||
async function requireSessionOrBearer(
|
||||
request: Request,
|
||||
headers: Record<string, string | undefined>
|
||||
) {
|
||||
const authHeader = headers["authorization"];
|
||||
if (authHeader === `Bearer ${BEARER_TOKEN}`) return;
|
||||
try {
|
||||
const session = await auth.api.getSession({ headers: request.headers });
|
||||
if (session) return;
|
||||
} catch {}
|
||||
throw new Error("Unauthorized");
|
||||
}
|
||||
|
||||
export const projectRoutes = new Elysia({ prefix: "/api/projects" })
|
||||
.onError(({ error, set }) => {
|
||||
const msg = error?.message || String(error);
|
||||
if (msg === "Unauthorized") {
|
||||
set.status = 401;
|
||||
return { error: "Unauthorized" };
|
||||
}
|
||||
if (msg === "Project not found") {
|
||||
set.status = 404;
|
||||
return { error: "Project not found" };
|
||||
}
|
||||
console.error("Project route error:", msg);
|
||||
set.status = 500;
|
||||
return { error: "Internal server error" };
|
||||
})
|
||||
|
||||
// GET all projects
|
||||
.get("/", async ({ request, headers }) => {
|
||||
await requireSessionOrBearer(request, headers);
|
||||
const allProjects = await db
|
||||
.select()
|
||||
.from(projects)
|
||||
.orderBy(asc(projects.name));
|
||||
return allProjects;
|
||||
})
|
||||
|
||||
// POST create project
|
||||
.post(
|
||||
"/",
|
||||
async ({ body, request, headers }) => {
|
||||
await requireSessionOrBearer(request, headers);
|
||||
const newProject = await db
|
||||
.insert(projects)
|
||||
.values({
|
||||
name: body.name,
|
||||
description: body.description,
|
||||
context: body.context,
|
||||
repos: body.repos || [],
|
||||
links: body.links || [],
|
||||
})
|
||||
.returning();
|
||||
return newProject[0];
|
||||
},
|
||||
{
|
||||
body: t.Object({
|
||||
name: t.String(),
|
||||
description: t.Optional(t.String()),
|
||||
context: t.Optional(t.String()),
|
||||
repos: t.Optional(t.Array(t.String())),
|
||||
links: t.Optional(
|
||||
t.Array(t.Object({ label: t.String(), url: t.String() }))
|
||||
),
|
||||
}),
|
||||
}
|
||||
)
|
||||
|
||||
// GET single project with its tasks
|
||||
.get(
|
||||
"/:id",
|
||||
async ({ params, request, headers }) => {
|
||||
await requireSessionOrBearer(request, headers);
|
||||
const project = await db
|
||||
.select()
|
||||
.from(projects)
|
||||
.where(eq(projects.id, params.id));
|
||||
if (!project.length) throw new Error("Project not found");
|
||||
|
||||
const projectTasks = await db
|
||||
.select()
|
||||
.from(tasks)
|
||||
.where(eq(tasks.projectId, params.id))
|
||||
.orderBy(asc(tasks.position), desc(tasks.createdAt));
|
||||
|
||||
return { ...project[0], tasks: projectTasks };
|
||||
},
|
||||
{ params: t.Object({ id: t.String() }) }
|
||||
)
|
||||
|
||||
// PATCH update project
|
||||
.patch(
|
||||
"/:id",
|
||||
async ({ params, body, request, headers }) => {
|
||||
await requireSessionOrBearer(request, headers);
|
||||
const updates: Record<string, any> = { updatedAt: new Date() };
|
||||
if (body.name !== undefined) updates.name = body.name;
|
||||
if (body.description !== undefined) updates.description = body.description;
|
||||
if (body.context !== undefined) updates.context = body.context;
|
||||
if (body.repos !== undefined) updates.repos = body.repos;
|
||||
if (body.links !== undefined) updates.links = body.links;
|
||||
|
||||
const updated = await db
|
||||
.update(projects)
|
||||
.set(updates)
|
||||
.where(eq(projects.id, params.id))
|
||||
.returning();
|
||||
if (!updated.length) throw new Error("Project not found");
|
||||
return updated[0];
|
||||
},
|
||||
{
|
||||
params: t.Object({ id: t.String() }),
|
||||
body: t.Object({
|
||||
name: t.Optional(t.String()),
|
||||
description: t.Optional(t.String()),
|
||||
context: t.Optional(t.String()),
|
||||
repos: t.Optional(t.Array(t.String())),
|
||||
links: t.Optional(
|
||||
t.Array(t.Object({ label: t.String(), url: t.String() }))
|
||||
),
|
||||
}),
|
||||
}
|
||||
)
|
||||
|
||||
// DELETE project (unlinks tasks, doesn't delete them)
|
||||
.delete(
|
||||
"/:id",
|
||||
async ({ params, request, headers }) => {
|
||||
await requireSessionOrBearer(request, headers);
|
||||
// Unlink tasks first
|
||||
await db
|
||||
.update(tasks)
|
||||
.set({ projectId: null, updatedAt: new Date() })
|
||||
.where(eq(tasks.projectId, params.id));
|
||||
// Delete project
|
||||
const deleted = await db
|
||||
.delete(projects)
|
||||
.where(eq(projects.id, params.id))
|
||||
.returning();
|
||||
if (!deleted.length) throw new Error("Project not found");
|
||||
return { success: true };
|
||||
},
|
||||
{ params: t.Object({ id: t.String() }) }
|
||||
);
|
||||
@@ -152,6 +152,7 @@ export const taskRoutes = new Elysia({ prefix: "/api/tasks" })
|
||||
priority: body.priority || "medium",
|
||||
position: (maxPos[0]?.max ?? 0) + 1,
|
||||
taskNumber: nextNumber,
|
||||
projectId: body.projectId || null,
|
||||
progressNotes: [],
|
||||
})
|
||||
.returning();
|
||||
@@ -188,6 +189,7 @@ export const taskRoutes = new Elysia({ prefix: "/api/tasks" })
|
||||
t.Literal("low"),
|
||||
])
|
||||
),
|
||||
projectId: t.Optional(t.Union([t.String(), t.Null()])),
|
||||
}),
|
||||
}
|
||||
)
|
||||
@@ -233,6 +235,7 @@ export const taskRoutes = new Elysia({ prefix: "/api/tasks" })
|
||||
if (body.position !== undefined) updates.position = body.position;
|
||||
if (body.assigneeId !== undefined) updates.assigneeId = body.assigneeId;
|
||||
if (body.assigneeName !== undefined) updates.assigneeName = body.assigneeName;
|
||||
if (body.projectId !== undefined) updates.projectId = body.projectId;
|
||||
|
||||
const updated = await db
|
||||
.update(tasks)
|
||||
@@ -259,6 +262,7 @@ export const taskRoutes = new Elysia({ prefix: "/api/tasks" })
|
||||
position: t.Optional(t.Number()),
|
||||
assigneeId: t.Optional(t.Union([t.String(), t.Null()])),
|
||||
assigneeName: t.Optional(t.Union([t.String(), t.Null()])),
|
||||
projectId: t.Optional(t.Union([t.String(), t.Null()])),
|
||||
}),
|
||||
}
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user