feat: projects with context - schema, API, frontend page, task assignment (HQ-17, HQ-21)
This commit is contained in:
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() }) }
|
||||
);
|
||||
Reference in New Issue
Block a user