File
Blob: src/worker/routes/workspaces.ts
| 1 | import { Hono } from "hono"; |
| 2 | import { eq, and, exists } from "drizzle-orm"; |
| 3 | import { ulid } from "ulid"; |
| 4 | |
| 5 | import type { AppContext } from "@/worker/app-context"; |
| 6 | import { |
| 7 | workspaces, |
| 8 | memberships, |
| 9 | users, |
| 10 | pages, |
| 11 | invites, |
| 12 | pageShares, |
| 13 | uploads, |
| 14 | workspaceSites, |
| 15 | publishedPages, |
| 16 | } from "@/worker/db/d1/schema"; |
| 17 | import { requireAuth } from "@/worker/middleware/auth"; |
| 18 | import { rateLimit } from "@/worker/middleware/rate-limit"; |
| 19 | import { checkMembership } from "@/worker/lib/membership"; |
| 20 | import { parseBody } from "@/worker/lib/validate"; |
| 21 | import { createLogger, errorContext } from "@/worker/lib/logger"; |
| 22 | import { bumpPublicSiteRevision } from "@/worker/lib/site-invalidation"; |
| 23 | import { CreateWorkspaceRequest, UpdateWorkspaceRequest, UpdateMemberRoleRequest } from "@/shared/types"; |
| 24 | import { canChangeMemberRole, canRemoveMember } from "@/shared/entitlements"; |
| 25 | |
| 26 | const workspacesRouter = new Hono<AppContext>(); |
| 27 | const log = createLogger("workspaces"); |
| 28 | |
| 29 | // POST /workspaces - Create workspace |
| 30 | workspacesRouter.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 |
| 80 | workspacesRouter.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 |
| 102 | workspacesRouter.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 |
| 153 | workspacesRouter.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. |
| 202 | workspacesRouter.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 |
| 245 | workspacesRouter.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 |
| 291 | workspacesRouter.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 | |
| 359 | export { workspacesRouter }; |