344 lines
10 KiB
JavaScript
344 lines
10 KiB
JavaScript
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;
|
|
}
|
|
}
|