diff --git a/apps/api/src/routes/projects.ts b/apps/api/src/routes/projects.ts new file mode 100644 index 0000000..22d15ae --- /dev/null +++ b/apps/api/src/routes/projects.ts @@ -0,0 +1,704 @@ +import { Hono } from "hono"; +import { db, projects, tasks, sections, projectTags, tags as tagsTable, activityFeed } from "@project-e/db"; +import { and, asc, desc, eq, ilike, inArray, isNull, sql } from "drizzle-orm"; +import { requireAuth, createErrorResponse, resolveActiveDomain, AuthError } from "../middleware/auth"; +import { recordActivity } from "../middleware/activity"; +import { z } from "zod"; + +export const projectRoutes = new Hono(); + +const projectStatusEnum = z.enum(["active", "paused", "completed", "archived"]); + +const createProjectSchema = z.object({ + name: z.string().min(1, "Name is required"), + description: z.string().optional().nullable(), + domain: z.string().min(1, "Domain is required"), + status: projectStatusEnum.optional().default("active"), + color: z.string().optional().nullable(), + icon: z.string().optional().nullable(), + targetDate: z.string().datetime().optional().nullable(), + tagIds: z.array(z.string().uuid()).optional(), +}); + +const updateProjectSchema = z.object({ + name: z.string().min(1).optional(), + description: z.string().optional().nullable(), + status: projectStatusEnum.optional(), + color: z.string().optional().nullable(), + icon: z.string().optional().nullable(), + targetDate: z.string().datetime().optional().nullable(), +}); + +const sectionKindEnum = z.enum(["section", "milestone"]); +const sectionStatusEnum = z.enum(["planned", "in_progress", "complete"]); + +const createSectionSchema = z.object({ + name: z.string().min(1, "Name is required"), + kind: sectionKindEnum.optional().default("section"), + status: sectionStatusEnum.optional().default("planned"), + targetDate: z.string().datetime().optional().nullable(), + sortOrder: z.number().int().optional(), +}); + +const updateSectionSchema = z.object({ + name: z.string().min(1).optional(), + kind: sectionKindEnum.optional(), + status: sectionStatusEnum.optional(), + targetDate: z.string().datetime().optional().nullable(), + sortOrder: z.number().int().optional(), +}); + +// GET /api/projects — List projects with filtering, sorting, pagination +projectRoutes.get("/", async (c) => { + try { + const user = await requireAuth(c); + const url = new URL(c.req.url); + const page = Math.max(1, parseInt(url.searchParams.get("page") || "1")); + const perPage = Math.min(100, Math.max(1, parseInt(url.searchParams.get("perPage") || "50"))); + const filter = url.searchParams.get("filter") || undefined; + const sort = url.searchParams.get("sort") || "-created"; + const status = url.searchParams.get("status"); + const search = url.searchParams.get("search"); + const limit = Math.min(parseInt(url.searchParams.get("limit") || "50"), 200); + const offset = parseInt(url.searchParams.get("offset") || "0"); + const order = url.searchParams.get("order") || "asc"; + + let domainId = url.searchParams.get("domain") || undefined; + if (!domainId) { + const active = await resolveActiveDomain(user); + domainId = active.id; + } + + const conditions: any[] = [ + eq(projects.domainId, domainId), + isNull(projects.deletedAt), + ]; + + if (status) { + const statuses = status.split(","); + conditions.push(inArray(projects.status, statuses as any)); + } + if (search) conditions.push(ilike(projects.name, `%${search}%`)); + if (filter) conditions.push(ilike(projects.name, `%${filter}%`)); + + const sortDir = sort.startsWith("-") ? "desc" : "asc"; + const sortField = sort.replace(/^-/, ""); + const sortColumns: Record = { + created: projects.createdAt, + updated: projects.updatedAt, + name: projects.name, + status: projects.status, + target_date: projects.targetDate, + created_at: projects.createdAt, + updated_at: projects.updatedAt, + }; + const orderColumn = sortDir === "asc" + ? asc(sortColumns[sortField] || projects.createdAt) + : desc(sortColumns[sortField] || projects.createdAt); + + const [items, countResult] = await Promise.all([ + db.select() + .from(projects) + .where(and(...conditions)) + .orderBy(orderColumn) + .limit(limit || perPage) + .offset(offset || (page - 1) * perPage), + db.select({ count: sql`count(*)` }) + .from(projects) + .where(and(...conditions)), + ]); + + const totalItems = Number(countResult[0]?.count || 0); + + // Fetch task counts and tags for all projects + let projectTagMap = new Map(); + let taskCountMap = new Map(); + + if (items.length > 0) { + const projectIds = items.map(p => p.id); + + // Tags + const tagRows = await db.select({ + projectId: projectTags.projectId, + id: tagsTable.id, + name: tagsTable.name, + color: tagsTable.color, + }) + .from(projectTags) + .innerJoin(tagsTable, eq(projectTags.tagId, tagsTable.id)) + .where(inArray(projectTags.projectId, projectIds)); + + for (const row of tagRows) { + if (!projectTagMap.has(row.projectId)) projectTagMap.set(row.projectId, []); + projectTagMap.get(row.projectId)!.push({ id: row.id, name: row.name, color: row.color }); + } + + // Task counts + for (const projectId of projectIds) { + const [totalResult] = await db.select({ count: sql`count(*)` }) + .from(tasks) + .where(and(eq(tasks.projectId, projectId), isNull(tasks.deletedAt))); + + const [completedResult] = await db.select({ count: sql`count(*)` }) + .from(tasks) + .where(and(eq(tasks.projectId, projectId), eq(tasks.status, "done"), isNull(tasks.deletedAt))); + + taskCountMap.set(projectId, { + total: Number(totalResult?.count || 0), + completed: Number(completedResult?.count || 0), + }); + } + } + + const itemsWithMeta = items.map(p => { + const counts = taskCountMap.get(p.id) || { total: 0, completed: 0 }; + return { + ...p, + tags: projectTagMap.get(p.id) || [], + taskCount: counts.total, + completedCount: counts.completed, + progress: counts.total > 0 ? Math.round((counts.completed / counts.total) * 100) : 0, + }; + }); + + return c.json({ + items: itemsWithMeta, + totalItems, + totalPages: Math.ceil(totalItems / (limit || perPage)), + page, + perPage: limit || perPage, + limit: limit || perPage, + offset: offset || (page - 1) * perPage, + }); + } catch (error) { + if (error instanceof AuthError) { + return c.json({ error: { code: error.code, message: error.message } }, error.status as any); + } + console.error("[projects] GET error:", error); + return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to list projects" } }, 500); + } +}); + +// POST /api/projects — Create a project +projectRoutes.post("/", async (c) => { + try { + const user = await requireAuth(c); + const body = await c.req.json(); + const data = createProjectSchema.parse({ + ...body, + domain: body.domain || (await resolveActiveDomain(user)).id, + }); + + const [project] = await db.insert(projects).values({ + name: data.name, + description: data.description ?? null, + status: data.status, + domainId: data.domain, + color: data.color ?? null, + icon: data.icon ?? null, + targetDate: data.targetDate ? new Date(data.targetDate) : null, + }).returning(); + + if (data.tagIds && data.tagIds.length > 0) { + await db.insert(projectTags).values( + data.tagIds.map(tagId => ({ projectId: project.id, tagId })) + ); + } + + await recordActivity({ + actor: user.name, + action: "created", + entityType: "project", + entityId: project.id, + changes: { name: project.name, status: project.status }, + workspaceId: data.domain, + }); + + return c.json(project, 201); + } catch (error) { + if (error instanceof AuthError) { + return c.json({ error: { code: error.code, message: error.message } }, error.status as any); + } + if (error instanceof z.ZodError) { + return c.json({ error: { code: "VALIDATION_ERROR", message: "Invalid input", details: error.issues } }, 400); + } + console.error("[projects] POST error:", error); + return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to create project" } }, 500); + } +}); + +// GET /api/projects/:id — Get a single project with sections, task counts, progress +projectRoutes.get("/:id", async (c) => { + try { + const user = await requireAuth(c); + const id = c.req.param("id"); + + const [project] = await db.select() + .from(projects) + .where(and(eq(projects.id, id), isNull(projects.deletedAt))) + .limit(1); + + if (!project) { + return c.json({ error: { code: "NOT_FOUND", message: "Project not found" } }, 404); + } + + // Fetch sections + const projectSections = await db.select() + .from(sections) + .where(eq(sections.projectId, id)) + .orderBy(asc(sections.sortOrder)); + + // Fetch tasks + const projectTasks = await db.select() + .from(tasks) + .where(and(eq(tasks.projectId, id), isNull(tasks.deletedAt))) + .orderBy(asc(tasks.order)); + + // Fetch tags + const tagRows = await db.select({ + id: tagsTable.id, + name: tagsTable.name, + color: tagsTable.color, + }) + .from(projectTags) + .innerJoin(tagsTable, eq(projectTags.tagId, tagsTable.id)) + .where(eq(projectTags.projectId, id)); + + const totalTasks = projectTasks.length; + const completedTasks = projectTasks.filter(t => t.status === "done").length; + const progress = totalTasks > 0 ? Math.round((completedTasks / totalTasks) * 100) : 0; + + return c.json({ + ...project, + sections: projectSections, + tasks: projectTasks, + tags: tagRows, + taskCount: totalTasks, + completedCount: completedTasks, + progress, + }); + } catch (error) { + if (error instanceof AuthError) { + return c.json({ error: { code: error.code, message: error.message } }, error.status as any); + } + console.error("[projects] GET/:id error:", error); + return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to get project" } }, 500); + } +}); + +// PATCH /api/projects/:id — Update a project +projectRoutes.patch("/:id", async (c) => { + try { + const user = await requireAuth(c); + const id = c.req.param("id"); + const body = await c.req.json(); + const data = updateProjectSchema.parse(body); + + const [existing] = await db.select() + .from(projects) + .where(and(eq(projects.id, id), isNull(projects.deletedAt))) + .limit(1); + + if (!existing) { + return c.json({ error: { code: "NOT_FOUND", message: "Project not found" } }, 404); + } + + const updateValues: Record = {}; + if (data.name !== undefined) updateValues.name = data.name; + if (data.description !== undefined) updateValues.description = data.description; + if (data.status !== undefined) updateValues.status = data.status; + if (data.color !== undefined) updateValues.color = data.color; + if (data.icon !== undefined) updateValues.icon = data.icon; + if (data.targetDate !== undefined) updateValues.targetDate = data.targetDate ? new Date(data.targetDate) : null; + updateValues.updatedAt = new Date(); + + const [updated] = await db.update(projects) + .set(updateValues) + .where(eq(projects.id, id)) + .returning(); + + await recordActivity({ + actor: user.name, + action: "updated", + entityType: "project", + entityId: id, + changes: { ...data, previousName: existing.name }, + workspaceId: existing.domainId, + }); + + return c.json(updated); + } catch (error) { + if (error instanceof AuthError) { + return c.json({ error: { code: error.code, message: error.message } }, error.status as any); + } + if (error instanceof z.ZodError) { + return c.json({ error: { code: "VALIDATION_ERROR", message: "Invalid input", details: error.issues } }, 400); + } + console.error("[projects] PATCH error:", error); + return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to update project" } }, 500); + } +}); + +// DELETE /api/projects/:id — Soft delete a project +projectRoutes.delete("/:id", async (c) => { + try { + const user = await requireAuth(c); + const id = c.req.param("id"); + + const [existing] = await db.select() + .from(projects) + .where(and(eq(projects.id, id), isNull(projects.deletedAt))) + .limit(1); + + if (!existing) { + return c.json({ error: { code: "NOT_FOUND", message: "Project not found" } }, 404); + } + + await db.update(projects) + .set({ deletedAt: new Date(), updatedAt: new Date() }) + .where(eq(projects.id, id)); + + await recordActivity({ + actor: user.name, + action: "deleted", + entityType: "project", + entityId: id, + changes: { name: existing.name }, + workspaceId: existing.domainId, + }); + + return c.body(null, 204); + } catch (error) { + if (error instanceof AuthError) { + return c.json({ error: { code: error.code, message: error.message } }, error.status as any); + } + console.error("[projects] DELETE error:", error); + return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to delete project" } }, 500); + } +}); + +// GET /api/projects/:id/sections — List sections for a project +projectRoutes.get("/:id/sections", async (c) => { + try { + const user = await requireAuth(c); + const projectId = c.req.param("id"); + + const [project] = await db.select({ id: projects.id }) + .from(projects) + .where(and(eq(projects.id, projectId), isNull(projects.deletedAt))) + .limit(1); + + if (!project) { + return c.json({ error: { code: "NOT_FOUND", message: "Project not found" } }, 404); + } + + const items = await db.select() + .from(sections) + .where(eq(sections.projectId, projectId)) + .orderBy(asc(sections.sortOrder)); + + return c.json({ items }); + } catch (error) { + if (error instanceof AuthError) { + return c.json({ error: { code: error.code, message: error.message } }, error.status as any); + } + console.error("[projects] GET /:id/sections error:", error); + return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to list sections" } }, 500); + } +}); + +// POST /api/projects/:id/sections — Create a section +projectRoutes.post("/:id/sections", async (c) => { + try { + const user = await requireAuth(c); + const projectId = c.req.param("id"); + const body = await c.req.json(); + const data = createSectionSchema.parse(body); + + const [project] = await db.select({ id: projects.id, name: projects.name, domainId: projects.domainId }) + .from(projects) + .where(and(eq(projects.id, projectId), isNull(projects.deletedAt))) + .limit(1); + + if (!project) { + return c.json({ error: { code: "NOT_FOUND", message: "Project not found" } }, 404); + } + + let sortOrder = data.sortOrder; + if (sortOrder === undefined) { + const [maxOrder] = await db.select({ max: sql`COALESCE(MAX(sort_order), -1)` }) + .from(sections) + .where(eq(sections.projectId, projectId)); + sortOrder = Number(maxOrder?.max || -1) + 1; + } + + const [section] = await db.insert(sections).values({ + name: data.name, + projectId, + kind: data.kind, + status: data.status, + targetDate: data.targetDate ? new Date(data.targetDate) : null, + sortOrder, + }).returning(); + + await recordActivity({ + actor: user.name, + action: "created", + entityType: "section", + entityId: section.id, + changes: { name: section.name, projectId, projectName: project.name, kind: section.kind }, + workspaceId: project.domainId, + }); + + return c.json(section, 201); + } catch (error) { + if (error instanceof AuthError) { + return c.json({ error: { code: error.code, message: error.message } }, error.status as any); + } + if (error instanceof z.ZodError) { + return c.json({ error: { code: "VALIDATION_ERROR", message: "Invalid input", details: error.issues } }, 400); + } + console.error("[projects] POST /:id/sections error:", error); + return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to create section" } }, 500); + } +}); + +// GET /api/projects/:id/sections/:sid — Get a single section +projectRoutes.get("/:id/sections/:sid", async (c) => { + try { + const user = await requireAuth(c); + const projectId = c.req.param("id"); + const id = c.req.param("sid"); + + const [section] = await db.select() + .from(sections) + .where(and(eq(sections.id, id), eq(sections.projectId, projectId))) + .limit(1); + + if (!section) { + return c.json({ error: { code: "NOT_FOUND", message: "Section not found" } }, 404); + } + + return c.json(section); + } catch (error) { + if (error instanceof AuthError) { + return c.json({ error: { code: error.code, message: error.message } }, error.status as any); + } + console.error("[projects] GET /:id/sections/:sid error:", error); + return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to get section" } }, 500); + } +}); + +// PATCH /api/projects/:id/sections/:sid — Update a section +projectRoutes.patch("/:id/sections/:sid", async (c) => { + try { + const user = await requireAuth(c); + const projectId = c.req.param("id"); + const id = c.req.param("sid"); + const body = await c.req.json(); + const data = updateSectionSchema.parse(body); + + const [existing] = await db.select() + .from(sections) + .where(and(eq(sections.id, id), eq(sections.projectId, projectId))) + .limit(1); + + if (!existing) { + return c.json({ error: { code: "NOT_FOUND", message: "Section not found" } }, 404); + } + + // Get project's domainId for activity recording + const [proj] = await db.select({ domainId: projects.domainId }) + .from(projects) + .where(eq(projects.id, projectId)) + .limit(1); + + const updateValues: Record = {}; + if (data.name !== undefined) updateValues.name = data.name; + if (data.kind !== undefined) updateValues.kind = data.kind; + if (data.status !== undefined) updateValues.status = data.status; + if (data.targetDate !== undefined) updateValues.targetDate = data.targetDate ? new Date(data.targetDate) : null; + if (data.sortOrder !== undefined) updateValues.sortOrder = data.sortOrder; + updateValues.updatedAt = new Date(); + + const [updated] = await db.update(sections) + .set(updateValues) + .where(eq(sections.id, id)) + .returning(); + + await recordActivity({ + actor: user.name, + action: "updated", + entityType: "section", + entityId: id, + changes: { ...data, previousName: existing.name, projectId }, + workspaceId: proj?.domainId || projectId, + }); + + return c.json(updated); + } catch (error) { + if (error instanceof AuthError) { + return c.json({ error: { code: error.code, message: error.message } }, error.status as any); + } + if (error instanceof z.ZodError) { + return c.json({ error: { code: "VALIDATION_ERROR", message: "Invalid input", details: error.issues } }, 400); + } + console.error("[projects] PATCH /:id/sections/:sid error:", error); + return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to update section" } }, 500); + } +}); + +// DELETE /api/projects/:id/sections/:sid — Delete a section +projectRoutes.delete("/:id/sections/:sid", async (c) => { + try { + const user = await requireAuth(c); + const projectId = c.req.param("id"); + const id = c.req.param("sid"); + + const [existing] = await db.select() + .from(sections) + .where(and(eq(sections.id, id), eq(sections.projectId, projectId))) + .limit(1); + + if (!existing) { + return c.json({ error: { code: "NOT_FOUND", message: "Section not found" } }, 404); + } + + // Get project's domainId for activity recording + const [proj] = await db.select({ domainId: projects.domainId }) + .from(projects) + .where(eq(projects.id, projectId)) + .limit(1); + + await db.delete(sections) + .where(eq(sections.id, id)); + + await recordActivity({ + actor: user.name, + action: "deleted", + entityType: "section", + entityId: id, + changes: { name: existing.name, projectId }, + workspaceId: proj?.domainId || projectId, + }); + + return c.body(null, 204); + } catch (error) { + if (error instanceof AuthError) { + return c.json({ error: { code: error.code, message: error.message } }, error.status as any); + } + console.error("[projects] DELETE /:id/sections/:sid error:", error); + return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to delete section" } }, 500); + } +}); + +// GET /api/projects/:id/members — List members (via activity feed for now) +projectRoutes.get("/:id/members", async (c) => { + try { + const user = await requireAuth(c); + const id = c.req.param("id"); + + const [project] = await db.select({ id: projects.id }) + .from(projects) + .where(and(eq(projects.id, id), isNull(projects.deletedAt))) + .limit(1); + + if (!project) { + return c.json({ error: { code: "NOT_FOUND", message: "Project not found" } }, 404); + } + + // Members are stored in activity feed with entityType=member + const members = await db.select() + .from(activityFeed) + .where(and( + eq(activityFeed.entityId, id), + eq(activityFeed.entityType, "member"), + )) + .orderBy(desc(activityFeed.createdAt)); + + return c.json({ items: members, totalItems: members.length }); + } catch (error) { + if (error instanceof AuthError) { + return c.json({ error: { code: error.code, message: error.message } }, error.status as any); + } + console.error("[projects] GET /:id/members error:", error); + return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to list members" } }, 500); + } +}); + +// POST /api/projects/:id/members — Add a member +projectRoutes.post("/:id/members", async (c) => { + try { + const user = await requireAuth(c); + const id = c.req.param("id"); + const body = await c.req.json(); + const { userId, role } = z.object({ + userId: z.string().uuid(), + role: z.string().optional().default("member"), + }).parse(body); + + const [project] = await db.select({ id: projects.id, name: projects.name, domainId: projects.domainId }) + .from(projects) + .where(and(eq(projects.id, id), isNull(projects.deletedAt))) + .limit(1); + + if (!project) { + return c.json({ error: { code: "NOT_FOUND", message: "Project not found" } }, 404); + } + + await recordActivity({ + actor: user.name, + action: "added", + entityType: "member", + entityId: id, + changes: { userId, role }, + workspaceId: project.domainId, + }); + + return c.json({ success: true }, 201); + } catch (error) { + if (error instanceof AuthError) { + return c.json({ error: { code: error.code, message: error.message } }, error.status as any); + } + if (error instanceof z.ZodError) { + return c.json({ error: { code: "VALIDATION_ERROR", message: "Invalid input", details: error.issues } }, 400); + } + console.error("[projects] POST /:id/members error:", error); + return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to add member" } }, 500); + } +}); + +// DELETE /api/projects/:id/members/:uid — Remove a member +projectRoutes.delete("/:id/members/:uid", async (c) => { + try { + const user = await requireAuth(c); + const id = c.req.param("id"); + const userId = c.req.param("uid"); + + const [project] = await db.select({ id: projects.id, name: projects.name, domainId: projects.domainId }) + .from(projects) + .where(and(eq(projects.id, id), isNull(projects.deletedAt))) + .limit(1); + + if (!project) { + return c.json({ error: { code: "NOT_FOUND", message: "Project not found" } }, 404); + } + + await recordActivity({ + actor: user.name, + action: "removed", + entityType: "member", + entityId: id, + changes: { userId }, + workspaceId: project.domainId, + }); + + return c.body(null, 204); + } catch (error) { + if (error instanceof AuthError) { + return c.json({ error: { code: error.code, message: error.message } }, error.status as any); + } + console.error("[projects] DELETE /:id/members/:uid error:", error); + return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to remove member" } }, 500); + } +});