import type { ProjectRole } from "@vtit-agent-coding/shared"; import type { Context, Next } from "hono"; import { Hono } from "hono"; import { HTTPException } from "hono/http-exception"; import { createBoard, getBoard } from "./boardRepo"; import { newId, parseJsonFields, queryDb } from "./db"; import { createProjectDocument, deleteProjectDocument, getProjectDocument, listProjectDocuments, updateProjectDocument } from "./projectDocsRepo"; import { addProjectAgent, addProjectMember, createProject, deleteProject, getProject, getProjectRole, listProjectAgents, listProjectMembers, listProjects, removeProjectAgent, removeProjectMember, searchUsers, updateProject, } from "./projectRepo"; import { createDraftTask, createRequirement, deleteDraftTask, getRequirement, listDraftTasks, listRequirements, updateDraftTask, } from "./projectRequirementsRepo"; import { createProjectRule, deleteProjectRule, getProjectRule, listProjectRules, updateProjectRule } from "./projectRulesRepo"; import { runRequirementPipeline } from "./requirementPipeline"; import { listTasks } from "./taskRepo"; import type { AppContext } from "./types"; export const projectRoutes = new Hono(); function getUserId(c: Context): string { const userId = c.get("ownerId") || c.get("user")?.id; if (!userId) throw new HTTPException(401, { message: "Unauthorized" }); return userId; } // ─── Role hierarchy helpers ────────────────────────────────────────────────── const ROLE_LEVEL: Record = { READ: 1, DEVELOPER: 2, MAINTAINER: 3, OWNER: 4, }; /** * Check if a user's role meets the minimum required role. * OWNER always has full access. */ function hasMinRole(userRole: ProjectRole | "OWNER", minRole: ProjectRole | "OWNER"): boolean { return ROLE_LEVEL[userRole] >= ROLE_LEVEL[minRole]; } /** * Middleware factory: checks that the authenticated user has at least `minRole` * on the project identified by :id route param. */ function requireProjectRole(minRole: ProjectRole | "OWNER") { return async (c: Context, next: Next) => { const userId = getUserId(c); const projectId = c.req.param("id"); if (!projectId) throw new HTTPException(400, { message: "Project ID is required" }); const role = await getProjectRole(c.env.DB, projectId, userId); if (!role) { throw new HTTPException(404, { message: "Project not found" }); } if (!hasMinRole(role, minRole)) { throw new HTTPException(403, { message: `Requires at least ${minRole} role` }); } // Stash the role so downstream handlers can use it if needed c.set("projectRole" as any, role); await next(); }; } // ─── Auth check middleware ─────────────────────────────────────────────────── // Handlers and requireProjectRole middleware enforce user authentication explicitly. // ─── User Search API (for member invitation) ──────────────────────────────── projectRoutes.get("/api/users/search", async (c) => { const q = c.req.query("q"); if (!q || q.trim().length < 1) { return c.json([]); } const users = await searchUsers(c.env.DB, q.trim()); return c.json(users); }); // ─── Projects CRUD ─────────────────────────────────────────────────────────── projectRoutes.get("/api/projects", async (c) => { const userId = getUserId(c); const projects = await listProjects(c.env.DB, userId); return c.json(projects); }); projectRoutes.post("/api/projects", async (c) => { const userId = getUserId(c); const body = await c.req.json(); if (!body.code || !body.name) { throw new HTTPException(400, { message: "Code and name are required" }); } try { const project = await createProject(c.env.DB, userId, body); // Auto create default board linked to this project const board = await createBoard(c.env.DB, userId, `${project.name} Board`, "dev", body.description || undefined); await queryDb(c.env.DB, "UPDATE boards SET project_id = $1 WHERE id = $2", [project.id, board.id]); return c.json({ ...project, board_id: board.id }, 201); } catch (err: any) { if (err?.message?.includes("UNIQUE constraint failed") || err?.message?.includes("idx_projects_owner_code")) { throw new HTTPException(409, { message: `Mã dự án '${body.code}' đã tồn tại. Vui lòng chọn mã khác.` }); } throw err; } }); // GET single project — READ or higher projectRoutes.get("/api/projects/:id", requireProjectRole("READ"), async (c) => { const userId = getUserId(c); const projectId = c.req.param("id")!; const project = await getProject(c.env.DB, projectId, userId); if (!project) throw new HTTPException(404, { message: "Project not found" }); const boardRes = await queryDb<{ id: string }>(c.env.DB, "SELECT id FROM boards WHERE project_id = $1 ORDER BY created_at ASC LIMIT 1", [ projectId, ]); const boardId = boardRes.rows[0]?.id || null; return c.json({ ...project, board_id: boardId }); }); // PUT project — DEVELOPER or higher projectRoutes.put("/api/projects/:id", requireProjectRole("DEVELOPER"), async (c) => { const userId = getUserId(c); const projectId = c.req.param("id")!; const body = await c.req.json(); const updated = await updateProject(c.env.DB, projectId, userId, body); if (!updated) throw new HTTPException(404, { message: "Project not found or not owner" }); return c.json(updated); }); // DELETE project — only OWNER or MAINTAINER projectRoutes.delete("/api/projects/:id", requireProjectRole("MAINTAINER"), async (c) => { const userId = getUserId(c); const projectId = c.req.param("id")!; const success = await deleteProject(c.env.DB, projectId, userId); if (!success) throw new HTTPException(404, { message: "Project not found or not owner" }); return c.json({ success: true }); }); // GET project board with tasks directly by projectId — READ or higher projectRoutes.get("/api/projects/:id/board", requireProjectRole("READ"), async (c) => { const userId = getUserId(c); const projectId = c.req.param("id")!; const boardRes = await queryDb<{ id: string }>(c.env.DB, "SELECT id FROM boards WHERE project_id = $1 ORDER BY created_at ASC LIMIT 1", [ projectId, ]); let boardId = boardRes.rows[0]?.id; if (!boardId) { const project = await getProject(c.env.DB, projectId, userId); if (!project) throw new HTTPException(404, { message: "Project not found" }); const board = await createBoard(c.env.DB, userId, `${project.name} Board`, "dev", project.description || undefined); await queryDb(c.env.DB, "UPDATE boards SET project_id = $1 WHERE id = $2", [projectId, board.id]); boardId = board.id; } const boardWithTasks = await getBoard(c.env.DB, boardId); if (!boardWithTasks) throw new HTTPException(404, { message: "Board not found" }); return c.json(boardWithTasks); }); // ─── Master Plan & Project Tasks ───────────────────────────────────────────── projectRoutes.get("/api/projects/:id/master-plan", requireProjectRole("READ"), async (c) => { const userId = getUserId(c); const projectId = c.req.param("id")!; const project = await getProject(c.env.DB, projectId, userId); if (!project) throw new HTTPException(404, { message: "Project not found" }); const boardRes = await queryDb<{ id: string }>(c.env.DB, "SELECT id FROM boards WHERE project_id = $1 ORDER BY created_at ASC LIMIT 1", [ projectId, ]); const boardId = boardRes.rows[0]?.id; if (!boardId) return c.json({ phases: [], tasks: [] }); const tasks = await listTasks(c.env.DB, userId, { board_id: boardId }); return c.json({ projectId, boardId, tasks }); }); // ─── Project Rules API ─────────────────────────────────────────────────────── // GET rules — READ or higher projectRoutes.get("/api/projects/:id/rules", requireProjectRole("READ"), async (c) => { const projectId = c.req.param("id")!; const rules = await listProjectRules(c.env.DB, projectId); return c.json(rules); }); projectRoutes.get("/api/projects/:id/rules/:ruleId", requireProjectRole("READ"), async (c) => { const projectId = c.req.param("id")!; const ruleId = c.req.param("ruleId")!; const rule = await getProjectRule(c.env.DB, projectId, ruleId); if (!rule) throw new HTTPException(404, { message: "Rule not found" }); return c.json(rule); }); // POST/PUT/DELETE rules — DEVELOPER or higher projectRoutes.post("/api/projects/:id/rules", requireProjectRole("DEVELOPER"), async (c) => { const projectId = c.req.param("id")!; const body = await c.req.json(); if (!body.file_name || !body.title) { throw new HTTPException(400, { message: "File name and title are required" }); } const rule = await createProjectRule(c.env.DB, projectId, body.file_name, body.title, body.content || ""); return c.json(rule, 201); }); projectRoutes.put("/api/projects/:id/rules/:ruleId", requireProjectRole("DEVELOPER"), async (c) => { const projectId = c.req.param("id")!; const ruleId = c.req.param("ruleId")!; const body = await c.req.json(); const rule = await updateProjectRule(c.env.DB, projectId, ruleId, body); if (!rule) throw new HTTPException(404, { message: "Rule not found" }); return c.json(rule); }); projectRoutes.delete("/api/projects/:id/rules/:ruleId", requireProjectRole("DEVELOPER"), async (c) => { const projectId = c.req.param("id")!; const ruleId = c.req.param("ruleId")!; const success = await deleteProjectRule(c.env.DB, projectId, ruleId); if (!success) throw new HTTPException(404, { message: "Rule not found" }); return c.json({ success: true }); }); // ─── Project Documents API ─────────────────────────────────────────────────── // GET docs — READ or higher projectRoutes.get("/api/projects/:id/docs", requireProjectRole("READ"), async (c) => { const projectId = c.req.param("id")!; const docs = await listProjectDocuments(c.env.DB, projectId); return c.json(docs); }); projectRoutes.get("/api/projects/:id/docs/:docId", requireProjectRole("READ"), async (c) => { const projectId = c.req.param("id")!; const docId = c.req.param("docId")!; const doc = await getProjectDocument(c.env.DB, projectId, docId); if (!doc) throw new HTTPException(404, { message: "Document not found" }); return c.json(doc); }); // POST/PUT/DELETE docs — DEVELOPER or higher projectRoutes.post("/api/projects/:id/docs", requireProjectRole("DEVELOPER"), async (c) => { const projectId = c.req.param("id")!; const body = await c.req.json(); if (!body.file_name || !body.title) { throw new HTTPException(400, { message: "File name and title are required" }); } const doc = await createProjectDocument(c.env.DB, projectId, body.category || "general", body.file_name, body.title, body.content || ""); return c.json(doc, 201); }); projectRoutes.put("/api/projects/:id/docs/:docId", requireProjectRole("DEVELOPER"), async (c) => { const projectId = c.req.param("id")!; const docId = c.req.param("docId")!; const body = await c.req.json(); const doc = await updateProjectDocument(c.env.DB, projectId, docId, body); if (!doc) throw new HTTPException(404, { message: "Document not found" }); return c.json(doc); }); projectRoutes.delete("/api/projects/:id/docs/:docId", requireProjectRole("DEVELOPER"), async (c) => { const projectId = c.req.param("id")!; const docId = c.req.param("docId")!; const success = await deleteProjectDocument(c.env.DB, projectId, docId); if (!success) throw new HTTPException(404, { message: "Document not found" }); return c.json({ success: true }); }); // ─── Project Members API ───────────────────────────────────────────────────── // GET members — READ or higher projectRoutes.get("/api/projects/:id/members", requireProjectRole("READ"), async (c) => { const projectId = c.req.param("id")!; const members = await listProjectMembers(c.env.DB, projectId); return c.json(members); }); // POST/DELETE members — only OWNER or MAINTAINER projectRoutes.post("/api/projects/:id/members", requireProjectRole("MAINTAINER"), async (c) => { const projectId = c.req.param("id")!; const body = await c.req.json(); if (!body.user_id || !body.role) { throw new HTTPException(400, { message: "user_id and role are required" }); } const member = await addProjectMember(c.env.DB, projectId, body.user_id, body.role); return c.json(member, 201); }); projectRoutes.delete("/api/projects/:id/members/:userId", requireProjectRole("MAINTAINER"), async (c) => { const projectId = c.req.param("id")!; const userId = c.req.param("userId")!; const success = await removeProjectMember(c.env.DB, projectId, userId); if (!success) throw new HTTPException(404, { message: "Member not found" }); return c.json({ success: true }); }); // ─── Project Agents API ────────────────────────────────────────────────────── // GET agents — READ or higher (used by Assignee dropdown) projectRoutes.get("/api/projects/:id/agents", requireProjectRole("READ"), async (c) => { const projectId = c.req.param("id")!; const agents = await listProjectAgents(c.env.DB, projectId); return c.json(agents); }); // POST/DELETE agents — MAINTAINER or higher projectRoutes.post("/api/projects/:id/agents", requireProjectRole("MAINTAINER"), async (c) => { const projectId = c.req.param("id")!; const body = await c.req.json(); if (!body.agent_id) throw new HTTPException(400, { message: "agent_id is required" }); await addProjectAgent(c.env.DB, projectId, body.agent_id); return c.json({ success: true }, 201); }); projectRoutes.delete("/api/projects/:id/agents/:agentId", requireProjectRole("MAINTAINER"), async (c) => { const projectId = c.req.param("id")!; const agentId = c.req.param("agentId")!; const success = await removeProjectAgent(c.env.DB, projectId, agentId); if (!success) throw new HTTPException(404, { message: "Agent assignment not found" }); return c.json({ success: true }); }); // ─── Project Requirements API ──────────────────────────────────────────────── // GET requirements — READ or higher projectRoutes.get("/api/projects/:id/requirements", requireProjectRole("READ"), async (c) => { const projectId = c.req.param("id")!; const requirements = await listRequirements(c.env.DB, projectId); return c.json(requirements); }); // GET single requirement — READ or higher projectRoutes.get("/api/projects/:id/requirements/:reqId", requireProjectRole("READ"), async (c) => { const projectId = c.req.param("id")!; const reqId = c.req.param("reqId")!; const requirement = await getRequirement(c.env.DB, reqId); if (!requirement || requirement.project_id !== projectId) { throw new HTTPException(404, { message: "Requirement not found" }); } const draftTasks = await listDraftTasks(c.env.DB, reqId); return c.json({ requirement, draftTasks }); }); // POST requirement — DEVELOPER or higher projectRoutes.post("/api/projects/:id/requirements", requireProjectRole("DEVELOPER"), async (c) => { const projectId = c.req.param("id")!; const body = await c.req.json(); if (!body.title || !body.description) { throw new HTTPException(400, { message: "Title and description are required" }); } const requirement = await createRequirement(c.env.DB, projectId, body); return c.json(requirement, 201); }); // POST draft task — DEVELOPER or higher projectRoutes.post("/api/projects/:id/requirements/:reqId/draft-tasks", requireProjectRole("DEVELOPER"), async (c) => { const projectId = c.req.param("id")!; const reqId = c.req.param("reqId")!; const requirement = await getRequirement(c.env.DB, reqId); if (!requirement || requirement.project_id !== projectId) { throw new HTTPException(404, { message: "Requirement not found" }); } const body = await c.req.json(); if (!body.title) { throw new HTTPException(400, { message: "Title is required" }); } const draftTask = await createDraftTask(c.env.DB, reqId, body); return c.json(draftTask, 201); }); // PUT draft task — DEVELOPER or higher projectRoutes.put("/api/projects/:id/requirements/:reqId/draft-tasks/:taskId", requireProjectRole("DEVELOPER"), async (c) => { const projectId = c.req.param("id")!; const reqId = c.req.param("reqId")!; const taskId = c.req.param("taskId")!; const requirement = await getRequirement(c.env.DB, reqId); if (!requirement || requirement.project_id !== projectId) { throw new HTTPException(404, { message: "Requirement not found" }); } const checkRes = await queryDb(c.env.DB, "SELECT requirement_id FROM requirement_draft_tasks WHERE id = $1", [taskId]); if (checkRes.rows.length === 0) { throw new HTTPException(404, { message: "Draft task not found" }); } const draftTaskReqId = (checkRes.rows[0] as any).requirement_id; if (draftTaskReqId !== reqId) { throw new HTTPException(400, { message: "Draft task does not belong to this requirement" }); } const body = await c.req.json(); const draftTask = await updateDraftTask(c.env.DB, taskId, body); return c.json(draftTask); }); // DELETE draft task — DEVELOPER or higher projectRoutes.delete("/api/projects/:id/requirements/:reqId/draft-tasks/:taskId", requireProjectRole("DEVELOPER"), async (c) => { const projectId = c.req.param("id")!; const reqId = c.req.param("reqId")!; const taskId = c.req.param("taskId")!; const requirement = await getRequirement(c.env.DB, reqId); if (!requirement || requirement.project_id !== projectId) { throw new HTTPException(404, { message: "Requirement not found" }); } const checkRes = await queryDb(c.env.DB, "SELECT requirement_id FROM requirement_draft_tasks WHERE id = $1", [taskId]); if (checkRes.rows.length === 0) { throw new HTTPException(404, { message: "Draft task not found" }); } const draftTaskReqId = (checkRes.rows[0] as any).requirement_id; if (draftTaskReqId !== reqId) { throw new HTTPException(400, { message: "Draft task does not belong to this requirement" }); } const success = await deleteDraftTask(c.env.DB, taskId); if (!success) { throw new HTTPException(404, { message: "Draft task not found" }); } return c.json({ success: true }); }); // POST run pipeline — DEVELOPER or higher projectRoutes.post("/api/projects/:id/requirements/:reqId/run", requireProjectRole("DEVELOPER"), async (c) => { const projectId = c.req.param("id")!; const reqId = c.req.param("reqId")!; const requirement = await getRequirement(c.env.DB, reqId); if (!requirement || requirement.project_id !== projectId) { throw new HTTPException(404, { message: "Requirement not found" }); } try { c.executionCtx.waitUntil(runRequirementPipeline(c.env, projectId, reqId)); } catch (_e) { runRequirementPipeline(c.env, projectId, reqId).catch(console.error); } return c.json({ success: true, status: "started" }); }); // POST import draft tasks — DEVELOPER or higher projectRoutes.post("/api/projects/:id/requirements/:reqId/import", requireProjectRole("DEVELOPER"), async (c) => { const projectId = c.req.param("id")!; const reqId = c.req.param("reqId")!; const requirement = await getRequirement(c.env.DB, reqId); if (!requirement || requirement.project_id !== projectId) { throw new HTTPException(404, { message: "Requirement not found" }); } const body = await c.req.json(); const { boardId, draftTaskIds } = body; if (!boardId) { throw new HTTPException(400, { message: "boardId is required" }); } // Verify that board exists and belongs to the project const boardRes = await queryDb(c.env.DB, "SELECT id, type FROM boards WHERE id = $1 AND project_id = $2", [boardId, projectId]); const board = boardRes.rows[0]; if (!board) { throw new HTTPException(404, { message: "Board not found or does not belong to this project" }); } // Get draft tasks from requirement that are NOT imported let query = "SELECT * FROM requirement_draft_tasks WHERE requirement_id = $1 AND status = 'draft'"; const binds: any[] = [reqId]; if (draftTaskIds && Array.isArray(draftTaskIds)) { if (draftTaskIds.length === 0) { return c.json({ success: true, count: 0, boardId }); } const placeholders = draftTaskIds.map((_, i) => `$${i + 2}`).join(", "); query += ` AND id IN (${placeholders})`; binds.push(...draftTaskIds); } const draftTasksRes = await queryDb(c.env.DB, query, binds); const draftTasks = draftTasksRes.rows; if (draftTasks.length === 0) { return c.json({ success: true, count: 0, boardId }); } const userId = getUserId(c); let importedCount = 0; for (const draftTask of draftTasks) { const taskId = newId(); // Pack metadata: { technicalNotes, steps, affectedFiles, businessContext, qcChecks, acceptanceTests } const parsedDraftTask = parseJsonFields(draftTask, ["affected_files", "steps", "qc_checks", "acceptance_tests"]); const technicalNotes = parsedDraftTask.technical_notes || null; const steps = parsedDraftTask.steps || null; const affectedFiles = parsedDraftTask.affected_files || null; const businessContext = parsedDraftTask.business_context || null; const qcChecks = parsedDraftTask.qc_checks || null; const acceptanceTests = parsedDraftTask.acceptance_tests || null; const metadata = { technicalNotes, steps, affectedFiles, businessContext, qcChecks, acceptanceTests, }; // Determine task position const maxPosRes = await queryDb<{ max_pos: number }>( c.env.DB, "SELECT COALESCE(MAX(position), -1) as max_pos FROM tasks WHERE board_id = $1 AND status = 'todo'", [boardId], ); const maxPos = maxPosRes.rows[0]?.max_pos ?? -1; const position = maxPos + 1; // Get board's next task seq const seqResult = await queryDb<{ task_seq: number }>(c.env.DB, "UPDATE boards SET task_seq = task_seq + 1 WHERE id = $1 RETURNING task_seq", [ boardId, ]); const seq = seqResult.rows[0]?.task_seq ?? 1; const metadataJson = JSON.stringify(metadata); try { await queryDb( c.env.DB, ` INSERT INTO tasks ( id, board_id, seq, phase, status, title, description, repository_id, labels, created_by, assigned_to, result, pr_url, input, metadata, created_from, scheduled_at, position, created_at, updated_at ) VALUES ($1, $2, $3, $4, 'todo', $5, $6, NULL, NULL, $7, NULL, NULL, NULL, NULL, $8, NULL, NULL, $9, NOW(), NOW()) `, [taskId, boardId, seq, "Phase 1", parsedDraftTask.title, parsedDraftTask.description || null, userId, metadataJson, position], ); } catch (err: any) { if (err?.message?.includes("no column named phase")) { await queryDb( c.env.DB, ` INSERT INTO tasks ( id, board_id, seq, status, title, description, repository_id, labels, created_by, assigned_to, result, pr_url, input, metadata, created_from, scheduled_at, position, created_at, updated_at ) VALUES ($1, $2, $3, 'todo', $4, $5, NULL, NULL, $6, NULL, NULL, NULL, NULL, NULL, $7, NULL, NULL, $8, NOW(), NOW()) `, [taskId, boardId, seq, parsedDraftTask.title, parsedDraftTask.description || null, userId, metadataJson, position], ); } else { throw err; } } // Add task action 'created' await queryDb( c.env.DB, "INSERT INTO task_actions (id, task_id, actor_type, actor_id, action, detail, session_id, created_at) VALUES ($1, $2, 'machine', 'system', 'created', NULL, NULL, NOW())", [newId(), taskId], ); // Update draft task to imported status await queryDb( c.env.DB, `UPDATE requirement_draft_tasks SET status = 'imported', imported_task_id = $1, updated_at = NOW() WHERE id = $2`, [taskId, parsedDraftTask.id], ); importedCount++; } return c.json({ success: true, count: importedCount, boardId }); }); // GET stream events — READ or higher projectRoutes.get("/api/projects/:id/requirements/:reqId/events", requireProjectRole("READ"), async (c) => { const projectId = c.req.param("id")!; const reqId = c.req.param("reqId")!; const requirement = await getRequirement(c.env.DB, reqId); if (!requirement || requirement.project_id !== projectId) { throw new HTTPException(404, { message: "Requirement not found" }); } const { readable, writable } = new TransformStream(); const writer = writable.getWriter(); const encoder = new TextEncoder(); const write = (event: string, data: any) => { let msg = `event: ${event}\n`; msg += `data: ${JSON.stringify(data)}\n\n`; return writer.write(encoder.encode(msg)); }; const runLoop = async () => { try { let req = await getRequirement(c.env.DB, reqId); let prevStatus = req?.status; await write("status", { status: req?.status }); const deadline = Date.now() + 25000; while (Date.now() < deadline) { await new Promise((r) => setTimeout(r, 2000)); req = await getRequirement(c.env.DB, reqId); if (req && req.status !== prevStatus) { prevStatus = req.status; await write("status", { status: req.status }); } if (req && (req.status === "completed" || req.status === "failed")) { break; } } } catch (e) { console.error(e); } finally { writer.close().catch(() => {}); } }; try { c.executionCtx.waitUntil(runLoop()); } catch (_e) { runLoop().catch(console.error); } return new Response(readable, { headers: { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", Connection: "keep-alive", }, }); });