import { db } from "./db.mjs"; import { requireCmsUser } from "./auth.mjs"; import { json, readBody } from "./http.mjs"; const projectRoles = ["owner", "admin", "editor", "viewer"]; const projectManagers = ["owner", "admin"]; function publicProject(row) { return { id: row.id, name: row.name, githubOwner: row.github_owner, githubRepo: row.github_repo, githubBranch: row.github_branch, siteRoot: row.site_root, createdAt: row.created_at, updatedAt: row.updated_at, ownerUserId: row.owner_user_id, }; } function publicMember(row) { return { id: row.id, projectId: row.project_id, userId: row.user_id, role: row.role, name: row.name, email: row.email, avatarUrl: row.avatar_url, status: row.status || "active", createdAt: row.created_at, }; } function getProject(projectId) { return db.prepare("SELECT * FROM projects WHERE id = ?").get(projectId); } function hasGlobalProjectAccess(user) { return user.role === "system_admin" || user.role === "admin"; } function getProjectMember(projectId, userId) { return db.prepare("SELECT * FROM project_members WHERE project_id = ? AND user_id = ?").get(projectId, userId); } function canViewProject(user, projectId) { return hasGlobalProjectAccess(user) || Boolean(getProjectMember(projectId, user.id)); } function canManageProject(user, projectId) { if (hasGlobalProjectAccess(user)) return true; const member = getProjectMember(projectId, user.id); return Boolean(member && projectManagers.includes(member.role)); } function requireProjectView(request, response, projectId) { const user = requireCmsUser(request, response); if (!user) return undefined; if (!canViewProject(user, projectId)) { json(response, 403, { error: "Permission denied" }); return undefined; } return user; } function requireProjectManage(request, response, projectId) { const user = requireCmsUser(request, response); if (!user) return undefined; if (!canManageProject(user, projectId)) { json(response, 403, { error: "Only project owners and admins can manage this project" }); return undefined; } return user; } function listMembers(projectId) { return db .prepare(` SELECT project_members.*, users.name, users.email, users.avatar_url, users.status FROM project_members INNER JOIN users ON users.id = project_members.user_id WHERE project_members.project_id = ? ORDER BY CASE project_members.role WHEN 'owner' THEN 1 WHEN 'admin' THEN 2 WHEN 'editor' THEN 3 ELSE 4 END, project_members.created_at ASC `) .all(projectId) .map(publicMember); } function listProjects(user) { if (user.role === "system_admin" || user.role === "admin") { return db.prepare("SELECT * FROM projects ORDER BY updated_at DESC, id DESC").all().map(publicProject); } return db .prepare(` SELECT projects.* FROM projects INNER JOIN project_members ON project_members.project_id = projects.id WHERE project_members.user_id = ? ORDER BY projects.updated_at DESC, projects.id DESC `) .all(user.id) .map(publicProject); } async function handleListProjects(request, response) { const user = requireCmsUser(request, response); if (!user) return; json(response, 200, { projects: listProjects(user) }); } async function handleCreateProject(request, response) { const user = requireCmsUser(request, response); if (!user) return; const body = JSON.parse((await readBody(request)) || "{}"); const name = String(body.name || "").trim(); if (!name) { json(response, 400, { error: "Project name is required" }); return; } const result = db .prepare(` INSERT INTO projects (owner_user_id, name, github_owner, github_repo, github_branch, site_root) VALUES (?, ?, ?, ?, ?, ?) `) .run( user.id, name, String(body.githubOwner || "").trim() || null, String(body.githubRepo || "").trim() || null, String(body.githubBranch || "main").trim() || "main", String(body.siteRoot || "docs").trim().replace(/^\/+|\/+$/g, "") || "docs", ); db.prepare("INSERT OR IGNORE INTO project_members (project_id, user_id, role) VALUES (?, ?, 'owner')").run( result.lastInsertRowid, user.id, ); const project = db.prepare("SELECT * FROM projects WHERE id = ?").get(result.lastInsertRowid); json(response, 201, { project: publicProject(project) }); } async function handleUpdateProject(request, response, projectId) { const project = db.prepare("SELECT * FROM projects WHERE id = ?").get(projectId); if (!project) { json(response, 404, { error: "Project not found" }); return; } const user = requireProjectManage(request, response, projectId); if (!user) return; const body = JSON.parse((await readBody(request)) || "{}"); db.prepare(` UPDATE projects SET name = ?, github_owner = ?, github_repo = ?, github_branch = ?, site_root = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? `).run( String(body.name || project.name).trim(), String(body.githubOwner ?? project.github_owner ?? "").trim() || null, String(body.githubRepo ?? project.github_repo ?? "").trim() || null, String(body.githubBranch || project.github_branch || "main").trim() || "main", String(body.siteRoot || project.site_root || "docs").trim().replace(/^\/+|\/+$/g, "") || "docs", projectId, ); json(response, 200, { project: publicProject(db.prepare("SELECT * FROM projects WHERE id = ?").get(projectId)) }); } async function handleListMembers(request, response, projectId) { if (!getProject(projectId)) { json(response, 404, { error: "Project not found" }); return; } const user = requireProjectView(request, response, projectId); if (!user) return; json(response, 200, { members: listMembers(projectId) }); } async function handleAddMember(request, response, projectId) { if (!getProject(projectId)) { json(response, 404, { error: "Project not found" }); return; } const user = requireProjectManage(request, response, projectId); if (!user) return; const body = JSON.parse((await readBody(request)) || "{}"); const email = String(body.email || "").trim().toLowerCase(); const role = String(body.role || "editor"); if (!projectRoles.includes(role)) { json(response, 400, { error: "Invalid member role" }); return; } const target = db.prepare("SELECT * FROM users WHERE email = ?").get(email); if (!target) { json(response, 404, { error: "User not found" }); return; } db.prepare(` INSERT INTO project_members (project_id, user_id, role) VALUES (?, ?, ?) ON CONFLICT(project_id, user_id) DO UPDATE SET role = excluded.role `).run(projectId, target.id, role); json(response, 200, { members: listMembers(projectId) }); } async function handleUpdateMember(request, response, projectId, memberId) { const member = db.prepare("SELECT * FROM project_members WHERE project_id = ? AND id = ?").get(projectId, memberId); if (!member) { json(response, 404, { error: "Member not found" }); return; } const user = requireProjectManage(request, response, projectId); if (!user) return; const body = JSON.parse((await readBody(request)) || "{}"); const role = String(body.role || member.role); if (!projectRoles.includes(role)) { json(response, 400, { error: "Invalid member role" }); return; } if (member.role === "owner" && role !== "owner") { const ownerCount = db .prepare("SELECT COUNT(*) AS count FROM project_members WHERE project_id = ? AND role = 'owner'") .get(projectId).count; if (ownerCount <= 1) { json(response, 400, { error: "At least one project owner is required" }); return; } } db.prepare("UPDATE project_members SET role = ? WHERE id = ?").run(role, memberId); json(response, 200, { members: listMembers(projectId) }); } async function handleRemoveMember(request, response, projectId, memberId) { const member = db.prepare("SELECT * FROM project_members WHERE project_id = ? AND id = ?").get(projectId, memberId); if (!member) { json(response, 404, { error: "Member not found" }); return; } const user = requireProjectManage(request, response, projectId); if (!user) return; if (member.role === "owner") { const ownerCount = db .prepare("SELECT COUNT(*) AS count FROM project_members WHERE project_id = ? AND role = 'owner'") .get(projectId).count; if (ownerCount <= 1) { json(response, 400, { error: "At least one project owner is required" }); return; } } db.prepare("DELETE FROM project_members WHERE id = ?").run(memberId); json(response, 200, { members: listMembers(projectId) }); } export async function handleProjectsApi(request, response, url) { try { if (url.pathname === "/api/projects" && request.method === "GET") { await handleListProjects(request, response); return true; } if (url.pathname === "/api/projects" && request.method === "POST") { await handleCreateProject(request, response); return true; } const match = url.pathname.match(/^\/api\/projects\/(\d+)$/); if (match && request.method === "PATCH") { await handleUpdateProject(request, response, Number(match[1])); return true; } const membersMatch = url.pathname.match(/^\/api\/projects\/(\d+)\/members$/); if (membersMatch && request.method === "GET") { await handleListMembers(request, response, Number(membersMatch[1])); return true; } if (membersMatch && request.method === "POST") { await handleAddMember(request, response, Number(membersMatch[1])); return true; } const memberMatch = url.pathname.match(/^\/api\/projects\/(\d+)\/members\/(\d+)$/); if (memberMatch && request.method === "PATCH") { await handleUpdateMember(request, response, Number(memberMatch[1]), Number(memberMatch[2])); return true; } if (memberMatch && request.method === "DELETE") { await handleRemoveMember(request, response, Number(memberMatch[1]), Number(memberMatch[2])); return true; } return false; } catch (error) { json(response, 500, { error: error instanceof Error ? error.message : "Unknown projects API error", }); return true; } }