Files
ProjectE/apps/api/src/routes/projects.ts
T
bot-hermes a60b75f075 feat: full plan execution - CI/CD, critical fixes, UX polish, secondary/advanced features, E2E + docs
Phase 0 (CI/CD): fix root typecheck to cover api+worker+web; reconcile migration
story into idempotent db:migrate (db:sync + db:triggers); add Gitea Actions
quality/deploy/smoke workflow; rewrite README/AGENTS/DEPLOY docs; add
requireWorkspaceAccess + recordActivityForEntity conventions.

Phase 1 (critical fixes): calendar delete + drag/resize DnD; canvas card CRUD +
bulk save + debounced autosave; logout route; graph edge workspaceId derivation;
real analytics endpoints (drop Math.random); task board droppable columns +
reorder persistence; Tiptap notes editor with sanitized HTML rendering; remove
insecure passkey auth; domain/owner scoping (IDOR) on all by-ID routes + search/
export/realtime scoping; command palette routing + agent mention fetch; agent
activity SSE handler; graph fly-to with tracked positions.

Phase 2 (UX polish): login on design system; Sonner toasts app-wide; shared
Loading/Empty/Error state components; working density/sidebarPos/reduce-motion
settings; Inter typography; consolidated status-colors lib; unified detail
routes; dashboard sort/realtime/responsive fixes; mobile responsive; a11y
(radiogroups, sanitized snippets, badge labels).

Phase 3 (features): daily notes timezone fix + delete + autosave + mood/energy
create; active-domain store + topbar picker; graph domain picker + navigable
entity links; tag assign/remove UI + server-side tag filter; real CSV export +
import validation; custom fields on tasks.

Phase 4 (advanced): migrate job worker into apps/worker (webhook delivery with
HMAC, recurring spawn, ai_dispatch disabled); webhook queue helper + entity
event enqueuing + test endpoint fix; recurring scheduledJobs pipeline; agents
CRUD + permission editing + activity filters; real notifications feed; MCP
polish (validation, error codes, domain scoping, dead sql leftover).

Phase 5 (E2E + docs): rewrite Playwright suite for the Vite SPA (15 specs, new
auth helpers, chromium-only in CI); add ephemeral-Postgres e2e CI job; rewrite
docs/API.md for the real Hono API.
2026-08-10 08:53:18 +00:00

765 lines
26 KiB
TypeScript

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, requireWorkspaceAccess, createErrorResponse, resolveActiveDomain, AuthError } from "../middleware/auth";
import { recordActivity } from "../middleware/activity";
import { enqueueWebhooks } from "../middleware/webhook-queue";
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;
}
await requireWorkspaceAccess(c, domainId);
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<string, any> = {
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<number>`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<string, { id: string; name: string; color: string | null }[]>();
let taskCountMap = new Map<string, { total: number; completed: number }>();
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<number>`count(*)` })
.from(tasks)
.where(and(eq(tasks.projectId, projectId), isNull(tasks.deletedAt)));
const [completedResult] = await db.select({ count: sql<number>`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,
});
await requireWorkspaceAccess(c, data.domain);
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,
});
await enqueueWebhooks({ workspaceId: data.domain, event: "project.created", entityType: "project", entityId: project.id, data: { name: project.name } });
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);
}
await requireWorkspaceAccess(c, project.domainId);
// 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);
}
await requireWorkspaceAccess(c, existing.domainId);
const updateValues: Record<string, unknown> = {};
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,
});
await enqueueWebhooks({ workspaceId: existing.domainId, event: "project.updated", entityType: "project", entityId: id, data: { ...data, previousName: existing.name } });
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 requireWorkspaceAccess(c, existing.domainId);
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,
});
await enqueueWebhooks({ workspaceId: existing.domainId, event: "project.deleted", entityType: "project", entityId: id, data: { name: existing.name } });
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, 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);
}
await requireWorkspaceAccess(c, project.domainId);
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);
}
await requireWorkspaceAccess(c, project.domainId);
let sortOrder = data.sortOrder;
if (sortOrder === undefined) {
const [maxOrder] = await db.select({ max: sql<number>`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 [project] = await db.select({ id: projects.id, 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);
}
await requireWorkspaceAccess(c, project.domainId);
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 [project] = await db.select({ id: projects.id, 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);
}
await requireWorkspaceAccess(c, project.domainId);
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<string, unknown> = {};
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 [project] = await db.select({ id: projects.id, 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);
}
await requireWorkspaceAccess(c, project.domainId);
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, 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 requireWorkspaceAccess(c, project.domainId);
// 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 requireWorkspaceAccess(c, project.domainId);
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 requireWorkspaceAccess(c, project.domainId);
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);
}
});