import { TRPCError } from "@trpc/server"; import { z } from "zod"; import { and, desc, eq, isNull, ne } from "drizzle-orm"; import { workspaces, workspaceMembers, users, } from "@tasks/database/schema"; import { router, protectedProcedure, workspaceProcedure } from "@/server/trpc"; import { findWorkspaceByHandle } from "@/server/lib/resolve-workspace"; 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", }); 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(); return updated; }), /** Owner-only soft archive. */ archive: workspaceProcedure.mutation(async ({ ctx }) => { if (ctx.workspace.role !== "owner") { throw new TRPCError({ code: "FORBIDDEN" }); } const [updated] = await ctx.db .update(workspaces) .set({ archivedAt: new Date() }) .where(eq(workspaces.id, ctx.workspace.id)) .returning(); return updated; }), });