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

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