import { TRPCError } from "@trpc/server"; import { z } from "zod"; import { and, desc, eq, isNull, ne, isNotNull } from "drizzle-orm"; import { workspaces, workspaceMembers, users, objects, } from "@tasks/database/schema"; import { router, protectedProcedure, workspaceProcedure } from "@/server/trpc"; import { findWorkspaceByHandle } from "@/server/lib/resolve-workspace"; import { recordAudit } from "@/server/lib/audit"; const slugSchema = z .string() .min(2) .max(60) .regex(/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/, "Slug must be lowercase, alphanumeric, hyphen-separated"); function makeSlug(name: string): string { return ( name .trim() .toLowerCase() .replace(/[^a-z0-9]+/g, "-") .replace(/^-+|-+$/g, "") .slice(0, 60) || "workspace" ); } export const workspacesRouter = router({ /** * Resolve a UUID-or-slug handle to a workspace the caller can see. Used by * the app shell to redirect / hydrate the workspace switcher. */ resolve: protectedProcedure .input(z.object({ handle: z.string().min(1) })) .query(async ({ ctx, input }) => { const ws = await findWorkspaceByHandle(input.handle, ctx.db); if (!ws) { throw new TRPCError({ code: "NOT_FOUND", message: "Workspace not found" }); } const userId = ctx.session.user.id; const [membership] = await ctx.db .select({ role: workspaceMembers.role }) .from(workspaceMembers) .where( and( eq(workspaceMembers.workspaceId, ws.id), eq(workspaceMembers.userId, userId), ), ) .limit(1); const [owner] = await ctx.db .select({ ownerUserId: workspaces.ownerUserId }) .from(workspaces) .where(eq(workspaces.id, ws.id)) .limit(1); if (!membership && owner?.ownerUserId !== userId) { throw new TRPCError({ code: "FORBIDDEN" }); } return ws; }), /** * Create a new workspace owned by the caller. Auto-mints a slug from `name` * unless one is provided. Caller is added as the owner+initial member. */ create: protectedProcedure .input( z.object({ name: z.string().min(1).max(200), slug: slugSchema.optional(), }), ) .mutation(async ({ ctx, input }) => { const userId = ctx.session.user.id; let slug = input.slug ?? makeSlug(input.name); const [collision] = await ctx.db .select({ id: workspaces.id }) .from(workspaces) .where(eq(workspaces.slug, slug)) .limit(1); if (collision) { if (input.slug) { throw new TRPCError({ code: "CONFLICT", message: "Slug already in use" }); } slug = `${slug}-${Math.random().toString(36).slice(2, 8)}`; } const [ws] = await ctx.db .insert(workspaces) .values({ name: input.name, slug, ownerUserId: userId, }) .returning(); await ctx.db.insert(workspaceMembers).values({ workspaceId: ws.id, userId, role: "owner", }); await recordAudit(ctx.db, { workspaceId: ws.id, actorUserId: userId, action: "workspace.create", targetType: "workspace", targetId: ws.id, metadata: { name: ws.name, slug: ws.slug }, }); return ws; }), /** All workspaces the caller owns or is a member of, owned-first then alpha. */ listForUser: protectedProcedure.query(async ({ ctx }) => { const userId = ctx.session.user.id; const owned = await ctx.db .select({ id: workspaces.id, slug: workspaces.slug, name: workspaces.name, role: workspaceMembers.role, archivedAt: workspaces.archivedAt, }) .from(workspaces) .leftJoin( workspaceMembers, and( eq(workspaceMembers.workspaceId, workspaces.id), eq(workspaceMembers.userId, userId), ), ) .where( and(eq(workspaces.ownerUserId, userId), isNull(workspaces.archivedAt)), ) .orderBy(workspaces.name); const memberOnly = await ctx.db .select({ id: workspaces.id, slug: workspaces.slug, name: workspaces.name, role: workspaceMembers.role, archivedAt: workspaces.archivedAt, }) .from(workspaceMembers) .innerJoin(workspaces, eq(workspaceMembers.workspaceId, workspaces.id)) .where( and( eq(workspaceMembers.userId, userId), ne(workspaces.ownerUserId, userId), isNull(workspaces.archivedAt), ), ) .orderBy(workspaces.name); return [...owned, ...memberOnly].map((row) => ({ id: row.id, slug: row.slug, name: row.name, role: row.role ?? "owner", archivedAt: row.archivedAt, })); }), /** Members of a workspace the caller can see. */ listMembers: workspaceProcedure.query(async ({ ctx }) => { return ctx.db .select({ id: users.id, name: users.name, email: users.email, avatarUrl: users.avatarUrl, role: workspaceMembers.role, }) .from(workspaceMembers) .innerJoin(users, eq(workspaceMembers.userId, users.id)) .where(eq(workspaceMembers.workspaceId, ctx.workspace.id)); }), /** * Update workspace metadata (name and/or slug). Slug renames are validated * for uniqueness; the caller must be the workspace owner. */ update: workspaceProcedure .input( z.object({ name: z.string().min(1).max(200).optional(), slug: slugSchema.optional(), }), ) .mutation(async ({ ctx, input }) => { if (ctx.workspace.role !== "owner") { throw new TRPCError({ code: "FORBIDDEN", message: "Only the owner can rename the workspace" }); } if (input.slug && input.slug !== ctx.workspace.slug) { const [collision] = await ctx.db .select({ id: workspaces.id }) .from(workspaces) .where(eq(workspaces.slug, input.slug)) .limit(1); if (collision) { throw new TRPCError({ code: "CONFLICT", message: "Slug already in use" }); } } const [updated] = await ctx.db .update(workspaces) .set({ ...(input.name ? { name: input.name } : {}), ...(input.slug ? { slug: input.slug } : {}), updatedAt: new Date(), }) .where(eq(workspaces.id, ctx.workspace.id)) .returning(); await recordAudit(ctx.db, { workspaceId: ctx.workspace.id, actorUserId: ctx.session.user.id, action: "workspace.update", targetType: "workspace", targetId: ctx.workspace.id, metadata: { ...(input.name ? { name: { before: ctx.workspace.name, after: input.name } } : {}), ...(input.slug ? { slug: { before: ctx.workspace.slug, after: input.slug } } : {}), }, }); return updated; }), /** * Change a member's role. Admin/owner only. Cannot demote the last owner * (the workspace would lose the ability to manage members). */ updateMemberRole: workspaceProcedure .input( z.object({ userId: z.string().uuid(), role: z.enum(["owner", "admin", "member"]), }), ) .mutation(async ({ ctx, input }) => { if (ctx.workspace.role !== "owner" && ctx.workspace.role !== "admin") { throw new TRPCError({ code: "FORBIDDEN", message: "Only owners and admins can change member roles.", }); } if (input.userId === ctx.session.user.id && input.role !== ctx.workspace.role) { throw new TRPCError({ code: "BAD_REQUEST", message: "You can't change your own role. Ask another owner or admin.", }); } const [target] = await ctx.db .select({ role: workspaceMembers.role }) .from(workspaceMembers) .where( and( eq(workspaceMembers.workspaceId, ctx.workspace.id), eq(workspaceMembers.userId, input.userId), ), ) .limit(1); if (!target) { throw new TRPCError({ code: "NOT_FOUND", message: "Member not found." }); } // Last-owner guard: demoting the only owner-role member to admin/member // would leave the workspace ownerless at the membership layer (even // though `workspaces.owner_user_id` still points at them — see the // ADR-pragmatic decision in Task-multi-email-identity convoy discussion). if (target.role === "owner" && input.role !== "owner") { const ownerCount = await ctx.db .select({ id: workspaceMembers.id }) .from(workspaceMembers) .where( and( eq(workspaceMembers.workspaceId, ctx.workspace.id), eq(workspaceMembers.role, "owner"), ), ); if (ownerCount.length <= 1) { throw new TRPCError({ code: "BAD_REQUEST", message: "Can't demote the only owner. Promote someone else first.", }); } } // Only owners can promote anyone to owner; admins can move people // between admin/member but cannot create another owner. if (input.role === "owner" && ctx.workspace.role !== "owner") { throw new TRPCError({ code: "FORBIDDEN", message: "Only an owner can promote someone to owner.", }); } await ctx.db .update(workspaceMembers) .set({ role: input.role }) .where( and( eq(workspaceMembers.workspaceId, ctx.workspace.id), eq(workspaceMembers.userId, input.userId), ), ); await recordAudit(ctx.db, { workspaceId: ctx.workspace.id, actorUserId: ctx.session.user.id, action: "member.role_change", targetType: "workspace_member", targetId: input.userId, metadata: { role: { before: target.role, after: input.role } }, }); return { ok: true as const }; }), /** * Remove a member from the workspace. Admin/owner only. Cannot remove the * last owner (same rationale as the demote guard above). Members can * remove themselves — that's the "leave workspace" affordance. */ removeMember: workspaceProcedure .input(z.object({ userId: z.string().uuid() })) .mutation(async ({ ctx, input }) => { const isSelf = input.userId === ctx.session.user.id; const callerCanManage = ctx.workspace.role === "owner" || ctx.workspace.role === "admin"; if (!isSelf && !callerCanManage) { throw new TRPCError({ code: "FORBIDDEN", message: "Only owners and admins can remove other members.", }); } const [target] = await ctx.db .select({ role: workspaceMembers.role }) .from(workspaceMembers) .where( and( eq(workspaceMembers.workspaceId, ctx.workspace.id), eq(workspaceMembers.userId, input.userId), ), ) .limit(1); if (!target) { throw new TRPCError({ code: "NOT_FOUND", message: "Member not found." }); } if (target.role === "owner") { const ownerCount = await ctx.db .select({ id: workspaceMembers.id }) .from(workspaceMembers) .where( and( eq(workspaceMembers.workspaceId, ctx.workspace.id), eq(workspaceMembers.role, "owner"), ), ); if (ownerCount.length <= 1) { throw new TRPCError({ code: "BAD_REQUEST", message: "Can't remove the only owner. Promote someone else to owner first.", }); } } // Admins cannot remove owners (only owners can de-owner an owner via // updateMemberRole -> removeMember, in that order). if (target.role === "owner" && ctx.workspace.role !== "owner" && !isSelf) { throw new TRPCError({ code: "FORBIDDEN", message: "Only an owner can remove another owner.", }); } await ctx.db .delete(workspaceMembers) .where( and( eq(workspaceMembers.workspaceId, ctx.workspace.id), eq(workspaceMembers.userId, input.userId), ), ); await recordAudit(ctx.db, { workspaceId: ctx.workspace.id, actorUserId: ctx.session.user.id, action: isSelf ? "member.leave" : "member.remove", targetType: "workspace_member", targetId: input.userId, metadata: { previous_role: target.role, self: isSelf }, }); return { ok: true as const }; }), /** * Owner-only soft archive. Stamps `archived_at` on the workspace row AND * cascades to every active `objects` row in the workspace inside the same * transaction — so a partial cascade (workspace archived, some objects * still active) is not a state we can land in. * * `markdown_backlog_items` is intentionally NOT cascaded here. Those rows * are sourced from disk by the file-watcher importer; if a workspace is * archived, restoring it just re-syncs from disk and the importer will * re-establish the rows. Filed as a follow-up if/when that assumption * stops holding. */ archive: workspaceProcedure.mutation(async ({ ctx }) => { if (ctx.workspace.role !== "owner") { throw new TRPCError({ code: "FORBIDDEN" }); } const archivedAt = new Date(); const result = await ctx.db.transaction(async (tx) => { const [updated] = await tx .update(workspaces) .set({ archivedAt }) .where(eq(workspaces.id, ctx.workspace.id)) .returning(); const archivedObjects = await tx .update(objects) .set({ archivedAt, updatedAt: archivedAt }) .where( and(eq(objects.workspaceId, ctx.workspace.id), isNull(objects.archivedAt)), ) .returning({ id: objects.id }); return { updated, cascadeCount: archivedObjects.length }; }); await recordAudit(ctx.db, { workspaceId: ctx.workspace.id, actorUserId: ctx.session.user.id, action: "workspace.archive", targetType: "workspace", targetId: ctx.workspace.id, metadata: { cascaded_objects: result.cascadeCount }, }); return result.updated; }), /** * Owner-only restore. Inverse of `archive`: clears `archived_at` on the * workspace and on every object that was archived as part of the same * cascade. We can't tell "was this object archived by the workspace * cascade vs. archived independently?" without recording per-object * archive provenance, so for v1 we restore EVERY archived object in the * workspace. That's the conservative-recovery behavior; if it surprises * anyone we'll add provenance tracking later. */ restore: workspaceProcedure.mutation(async ({ ctx }) => { if (ctx.workspace.role !== "owner") { throw new TRPCError({ code: "FORBIDDEN" }); } const [current] = await ctx.db .select({ archivedAt: workspaces.archivedAt }) .from(workspaces) .where(eq(workspaces.id, ctx.workspace.id)) .limit(1); if (!current?.archivedAt) { throw new TRPCError({ code: "BAD_REQUEST", message: "Workspace is not archived.", }); } const result = await ctx.db.transaction(async (tx) => { const [updated] = await tx .update(workspaces) .set({ archivedAt: null }) .where(eq(workspaces.id, ctx.workspace.id)) .returning(); const restoredObjects = await tx .update(objects) .set({ archivedAt: null, updatedAt: new Date() }) .where( and( eq(objects.workspaceId, ctx.workspace.id), isNotNull(objects.archivedAt), ), ) .returning({ id: objects.id }); return { updated, cascadeCount: restoredObjects.length }; }); await recordAudit(ctx.db, { workspaceId: ctx.workspace.id, actorUserId: ctx.session.user.id, action: "workspace.restore", targetType: "workspace", targetId: ctx.workspace.id, metadata: { restored_objects: result.cascadeCount }, }); return result.updated; }), });