feat: projects with context - schema, API, frontend page, task assignment (HQ-17, HQ-21)

This commit is contained in:
2026-01-29 05:05:20 +00:00
parent 8685548206
commit b0559cdbc8
10 changed files with 963 additions and 6 deletions

View File

@@ -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(),

View File

@@ -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.)

View 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() }) }
);

View File

@@ -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()])),
}),
}
)