Skip to content
File

Blob: src/worker/routes/workspaces.ts

typescript360 lines
1import { Hono } from "hono";
2import { eq, and, exists } from "drizzle-orm";
3import { ulid } from "ulid";
4 
5import type { AppContext } from "@/worker/app-context";
6import {
7 workspaces,
8 memberships,
9 users,
10 pages,
11 invites,
12 pageShares,
13 uploads,
14 workspaceSites,
15 publishedPages,
16} from "@/worker/db/d1/schema";
17import { requireAuth } from "@/worker/middleware/auth";
18import { rateLimit } from "@/worker/middleware/rate-limit";
19import { checkMembership } from "@/worker/lib/membership";
20import { parseBody } from "@/worker/lib/validate";
21import { createLogger, errorContext } from "@/worker/lib/logger";
22import { bumpPublicSiteRevision } from "@/worker/lib/site-invalidation";
23import { CreateWorkspaceRequest, UpdateWorkspaceRequest, UpdateMemberRoleRequest } from "@/shared/types";
24import { canChangeMemberRole, canRemoveMember } from "@/shared/entitlements";
25 
26const workspacesRouter = new Hono<AppContext>();
27const log = createLogger("workspaces");
28 
29// POST /workspaces - Create workspace
30workspacesRouter.post("/workspaces", requireAuth, rateLimit("RL_API"), async (c) => {
31 const data = await parseBody(c, CreateWorkspaceRequest);
32 if (data instanceof Response) return data;
33 
34 const { name, slug, icon } = data;
35 const user = c.get("user")!;
36 const db = c.get("db");
37 
38 // Check slug uniqueness
39 const existing = await db.select({ id: workspaces.id }).from(workspaces).where(eq(workspaces.slug, slug)).get();
40 
41 if (existing) {
42 return c.json({ error: "conflict", message: "A workspace with this slug already exists" }, 409);
43 }
44 
45 const workspaceId = ulid();
46 
47 await db.batch([
48 db.insert(workspaces).values({
49 id: workspaceId,
50 name,
51 slug,
52 icon: icon ?? null,
53 owner_id: user.id,
54 }),
55 db.insert(memberships).values({
56 user_id: user.id,
57 workspace_id: workspaceId,
58 role: "owner",
59 }),
60 ]);
61 
62 log.info("workspace_created", { workspaceId, slug, userId: user.id });
63 
64 return c.json(
65 {
66 workspace: {
67 id: workspaceId,
68 name,
69 slug,
70 icon: icon ?? null,
71 owner_id: user.id,
72 created_at: new Date().toISOString(),
73 },
74 },
75 201,
76 );
77});
78 
79// GET /workspaces - List user's workspaces
80workspacesRouter.get("/workspaces", requireAuth, rateLimit("RL_API"), async (c) => {
81 const user = c.get("user")!;
82 const db = c.get("db");
83 
84 const result = await db
85 .select({
86 id: workspaces.id,
87 name: workspaces.name,
88 slug: workspaces.slug,
89 icon: workspaces.icon,
90 owner_id: workspaces.owner_id,
91 created_at: workspaces.created_at,
92 role: memberships.role,
93 })
94 .from(memberships)
95 .innerJoin(workspaces, eq(memberships.workspace_id, workspaces.id))
96 .where(eq(memberships.user_id, user.id));
97 
98 return c.json({ workspaces: result });
99});
100 
101// PATCH /workspaces/:id - Update workspace
102workspacesRouter.patch("/workspaces/:id", requireAuth, rateLimit("RL_API"), async (c) => {
103 const workspaceId = c.req.param("id");
104 const user = c.get("user")!;
105 const db = c.get("db");
106 
107 const membership = await checkMembership(db, user.id, workspaceId);
108 if (!membership || membership.role !== "owner") {
109 return c.json({ error: "forbidden", message: "Only the workspace owner can update workspace settings" }, 403);
110 }
111 
112 const data = await parseBody(c, UpdateWorkspaceRequest);
113 if (data instanceof Response) return data;
114 
115 if (Object.keys(data).length === 0) {
116 return c.json({ error: "bad_request", message: "No fields to update" }, 400);
117 }
118 
119 // Check slug uniqueness if changing slug
120 if (data.slug) {
121 const existing = await db
122 .select({ id: workspaces.id })
123 .from(workspaces)
124 .where(eq(workspaces.slug, data.slug))
125 .get();
126 
127 if (existing && existing.id !== workspaceId) {
128 return c.json({ error: "conflict", message: "A workspace with this slug already exists" }, 409);
129 }
130 }
131 
132 const updateValues: Record<string, unknown> = {};
133 if (data.name !== undefined) updateValues.name = data.name;
134 if (data.slug !== undefined) updateValues.slug = data.slug;
135 if (data.icon !== undefined) updateValues.icon = data.icon;
136 
137 await db.update(workspaces).set(updateValues).where(eq(workspaces.id, workspaceId));
138 if (data.name !== undefined || data.icon !== undefined) {
139 await bumpPublicSiteRevision(db, workspaceId);
140 }
141 log.info("workspace_updated", { workspaceId, fields: Object.keys(updateValues) });
142 
143 const updated = await db.select().from(workspaces).where(eq(workspaces.id, workspaceId)).get();
144 
145 if (!updated) {
146 return c.json({ error: "not_found", message: "Workspace not found" }, 404);
147 }
148 
149 return c.json({ workspace: updated });
150});
151 
152// DELETE /workspaces/:id - Delete workspace
153workspacesRouter.delete("/workspaces/:id", requireAuth, rateLimit("RL_API"), async (c) => {
154 const workspaceId = c.req.param("id");
155 const user = c.get("user")!;
156 const db = c.get("db");
157 
158 const membership = await checkMembership(db, user.id, workspaceId);
159 if (!membership || membership.role !== "owner") {
160 return c.json({ error: "forbidden", message: "Only the workspace owner can delete it" }, 403);
161 }
162 
163 const workspacePages = await db.select({ id: pages.id }).from(pages).where(eq(pages.workspace_id, workspaceId));
164 const pageIds = workspacePages.map((p) => p.id);
165 
166 // Clear search index from WorkspaceIndexer DO before D1 cleanup
167 try {
168 const indexer = c.env.WorkspaceIndexer.getByName(workspaceId);
169 await indexer.clear();
170 } catch (e) {
171 log.error("workspace_indexer_clear_failed", { workspaceId, error: e instanceof Error ? e.message : String(e) });
172 }
173 
174 const batchOps = [
175 db.delete(uploads).where(eq(uploads.workspace_id, workspaceId)),
176 ...pageIds.flatMap((pid) => [db.delete(pageShares).where(eq(pageShares.page_id, pid))]),
177 db.delete(publishedPages).where(eq(publishedPages.workspace_id, workspaceId)),
178 db.delete(workspaceSites).where(eq(workspaceSites.workspace_id, workspaceId)),
179 db.delete(pages).where(eq(pages.workspace_id, workspaceId)),
180 db.delete(memberships).where(eq(memberships.workspace_id, workspaceId)),
181 db.delete(invites).where(eq(invites.workspace_id, workspaceId)),
182 db.delete(workspaces).where(eq(workspaces.id, workspaceId)),
183 ];
184 
185 // db.batch() requires a non-empty tuple type; cast needed for dynamically-built arrays
186 await db.batch(batchOps as [(typeof batchOps)[number], ...(typeof batchOps)[number][]]);
187 log.info("workspace_deleted", { workspaceId, userId: user.id, pageCount: pageIds.length });
188 
189 try {
190 await c.env.TASKS_QUEUE.send({ type: "workspace-sites-cleanup", workspaceId });
191 } catch (e) {
192 log.error("sites_cleanup_enqueue_failed", { workspaceId, ...errorContext(e) });
193 }
194 
195 return c.json({ ok: true });
196});
197 
198// GET /workspaces/:id/members - List members
199// Guest callers receive a self-only projection so they can reach the Leave
200// Workspace action without the roster being disclosed. Byline/avatar lookups
201// for other users on guest surfaces degrade to "Unknown" as a consequence.
202workspacesRouter.get("/workspaces/:id/members", requireAuth, rateLimit("RL_API"), async (c) => {
203 const workspaceId = c.req.param("id");
204 const user = c.get("user")!;
205 const db = c.get("db");
206 
207 const membership = await checkMembership(db, user.id, workspaceId);
208 if (!membership) {
209 return c.json({ error: "forbidden", message: "You are not a member of this workspace" }, 403);
210 }
211 
212 const scopedByUserId = membership.role === "guest" ? user.id : null;
213 
214 const baseQuery = db
215 .select({
216 user_id: memberships.user_id,
217 workspace_id: memberships.workspace_id,
218 role: memberships.role,
219 joined_at: memberships.joined_at,
220 id: users.id,
221 email: users.email,
222 name: users.name,
223 avatar_url: users.avatar_url,
224 user_created_at: users.created_at,
225 })
226 .from(memberships)
227 .innerJoin(users, eq(memberships.user_id, users.id));
228 
229 const rows = scopedByUserId
230 ? await baseQuery.where(and(eq(memberships.workspace_id, workspaceId), eq(memberships.user_id, scopedByUserId)))
231 : await baseQuery.where(eq(memberships.workspace_id, workspaceId));
232 
233 const members = rows.map((row) => ({
234 user_id: row.user_id,
235 workspace_id: row.workspace_id,
236 role: row.role,
237 joined_at: row.joined_at,
238 user: { id: row.id, email: row.email, name: row.name, avatar_url: row.avatar_url, created_at: row.user_created_at },
239 }));
240 
241 return c.json({ members });
242});
243 
244// PATCH /workspaces/:id/members/:uid - Change member role
245workspacesRouter.patch("/workspaces/:id/members/:uid", requireAuth, rateLimit("RL_API"), async (c) => {
246 const workspaceId = c.req.param("id");
247 const targetUserId = c.req.param("uid");
248 const user = c.get("user")!;
249 const db = c.get("db");
250 
251 const callerMembership = await checkMembership(db, user.id, workspaceId);
252 // Caller-authorization fast-fail skips the target lookup when the caller
253 // has no role-change authority, so unauthorized callers never learn target
254 // existence via a 404.
255 if (!callerMembership || (callerMembership.role !== "owner" && callerMembership.role !== "admin")) {
256 return c.json({ error: "forbidden", message: "Only owners and admins can change member roles" }, 403);
257 }
258 
259 const data = await parseBody(c, UpdateMemberRoleRequest);
260 if (data instanceof Response) return data;
261 
262 const { role } = data;
263 
264 const targetMembership = await checkMembership(db, targetUserId, workspaceId);
265 if (!targetMembership) {
266 return c.json({ error: "not_found", message: "Member not found" }, 404);
267 }
268 
269 // Policy enforced via the shared member-management entitlement. Error
270 // messages are preserved verbatim so client parsing does not regress.
271 if (!canChangeMemberRole(callerMembership.role, targetMembership.role, role)) {
272 if (targetMembership.role === "owner") {
273 return c.json({ error: "forbidden", message: "Cannot change the owner's role" }, 403);
274 }
275 if (role === "admin" && callerMembership.role !== "owner") {
276 return c.json({ error: "forbidden", message: "Only the owner can promote members to admin" }, 403);
277 }
278 return c.json({ error: "forbidden", message: "Only owners and admins can change member roles" }, 403);
279 }
280 
281 await db
282 .update(memberships)
283 .set({ role })
284 .where(and(eq(memberships.user_id, targetUserId), eq(memberships.workspace_id, workspaceId)));
285 log.info("member_role_changed", { workspaceId, targetUserId, role, byUserId: user.id });
286 
287 return c.json({ ok: true, role });
288});
289 
290// DELETE /workspaces/:id/members/:uid - Remove member
291workspacesRouter.delete("/workspaces/:id/members/:uid", requireAuth, rateLimit("RL_API"), async (c) => {
292 const workspaceId = c.req.param("id");
293 const targetUserId = c.req.param("uid");
294 const user = c.get("user")!;
295 const db = c.get("db");
296 
297 const callerMembership = await checkMembership(db, user.id, workspaceId);
298 if (!callerMembership) {
299 return c.json({ error: "forbidden", message: "You are not a member of this workspace" }, 403);
300 }
301 
302 const isSelf = user.id === targetUserId;
303 
304 // Self-leave policy only depends on the caller's own role, so we can decide
305 // owner-leave rejection without a second DB lookup.
306 if (isSelf) {
307 if (!canRemoveMember(callerMembership.role, callerMembership.role, true)) {
308 return c.json(
309 { error: "forbidden", message: "The owner cannot leave the workspace. Transfer ownership first." },
310 403,
311 );
312 }
313 } else {
314 // Caller-authorization fast-fail skips the target lookup when the caller
315 // has no removal authority, so unauthorized callers never learn target
316 // existence via a 404.
317 if (callerMembership.role !== "owner" && callerMembership.role !== "admin") {
318 return c.json({ error: "forbidden", message: "Only owners and admins can remove members" }, 403);
319 }
320 const targetMembership = await checkMembership(db, targetUserId, workspaceId);
321 if (!targetMembership) {
322 return c.json({ error: "not_found", message: "Member not found" }, 404);
323 }
324 if (!canRemoveMember(callerMembership.role, targetMembership.role, false)) {
325 if (targetMembership.role === "owner") {
326 return c.json({ error: "forbidden", message: "Cannot remove the workspace owner" }, 403);
327 }
328 if (targetMembership.role === "admin" && callerMembership.role !== "owner") {
329 return c.json({ error: "forbidden", message: "Only the owner can remove admins" }, 403);
330 }
331 return c.json({ error: "forbidden", message: "Only owners and admins can remove members" }, 403);
332 }
333 }
334 
335 // Atomic: drop the removed user's workspace-scoped user-grantee page_shares in the
336 // same D1 batch/transaction as the membership row. Use a correlated EXISTS so D1
337 // can narrow by grantee first, then confirm the page belongs to this workspace.
338 // `created_by` link shares remain intact — they were authored for other grantees.
339 await db.batch([
340 db.delete(pageShares).where(
341 and(
342 eq(pageShares.grantee_type, "user"),
343 eq(pageShares.grantee_id, targetUserId),
344 exists(
345 db
346 .select({ id: pages.id })
347 .from(pages)
348 .where(and(eq(pages.id, pageShares.page_id), eq(pages.workspace_id, workspaceId))),
349 ),
350 ),
351 ),
352 db.delete(memberships).where(and(eq(memberships.user_id, targetUserId), eq(memberships.workspace_id, workspaceId))),
353 ]);
354 log.info("member_removed", { workspaceId, targetUserId, isSelf, byUserId: user.id });
355 
356 return c.json({ ok: true });
357});
358 
359export { workspacesRouter };