/** * Team management routes (CRUD). * * GET /api/v1/teams — List all teams with member count * POST /api/v1/teams — Create team (admin only) * PUT /api/v1/teams/:id — Rename team (admin only) * DELETE /api/v1/teams/:id — Delete team (admin only) */ import { randomUUID } from "node:crypto"; import { eq, sql } from "drizzle-orm"; import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; import { z } from "zod"; import { db, schema } from "../db/index.js"; import { requirePermission } from "../permissions.js"; const teamBodySchema = z.object({ name: z .string({ required_error: "Team name is required" }) .transform((v) => v.trim()) .pipe( z .string() .min(1, "Team name is required") .max(50, "Team name must be 50 characters or fewer"), ), storageQuota: z.number().int().positive().nullable().optional(), retentionHours: z.number().int().positive().nullable().optional(), }); export async function teamsRoutes(app: FastifyInstance): Promise { // GET /api/v1/teams — List all teams with member count (admin only) app.get("/api/v1/teams", async (request: FastifyRequest, reply: FastifyReply) => { const user = await requirePermission("teams:manage")(request, reply); if (!user) return; const teams = await db .select({ id: schema.teams.id, name: schema.teams.name, storageQuota: schema.teams.storageQuota, retentionHours: schema.teams.retentionHours, memberCount: sql`(SELECT COUNT(*)::int FROM users WHERE users.team = ${schema.teams.id})`, createdAt: schema.teams.createdAt, }) .from(schema.teams); return reply.send({ teams: teams.map((t) => ({ ...t, createdAt: t.createdAt.toISOString(), })), }); }); // POST /api/v1/teams — Create team (admin only) app.post("/api/v1/teams", async (request: FastifyRequest, reply: FastifyReply) => { const admin = await requirePermission("teams:manage")(request, reply); if (!admin) return; const parsed = teamBodySchema.safeParse(request.body); if (!parsed.success) { return reply.status(400).send({ error: parsed.error.issues.map((i) => i.message).join("; "), code: "VALIDATION_ERROR", }); } const { name: trimmedName, storageQuota, retentionHours } = parsed.data; // Check for duplicate name (case-insensitive) const [existing] = await db .select() .from(schema.teams) .where(sql`LOWER(${schema.teams.name}) = LOWER(${trimmedName})`); if (existing) { return reply.status(409).send({ error: "Team name already exists", code: "CONFLICT" }); } const id = randomUUID(); await db.insert(schema.teams).values({ id, name: trimmedName, storageQuota: storageQuota ?? null, retentionHours: retentionHours ?? null, }); return reply.status(201).send({ id, name: trimmedName, storageQuota: storageQuota ?? null, retentionHours: retentionHours ?? null, }); }); // PUT /api/v1/teams/:id — Rename team (admin only) app.put( "/api/v1/teams/:id", async (request: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) => { const admin = await requirePermission("teams:manage")(request, reply); if (!admin) return; const { id } = request.params; const [team] = await db.select().from(schema.teams).where(eq(schema.teams.id, id)); if (!team) { return reply.status(404).send({ error: "Team not found", code: "NOT_FOUND" }); } const parsed = teamBodySchema.safeParse(request.body); if (!parsed.success) { return reply.status(400).send({ error: parsed.error.issues.map((i) => i.message).join("; "), code: "VALIDATION_ERROR", }); } const { name: trimmedName, storageQuota, retentionHours } = parsed.data; // Check for duplicate name (case-insensitive), excluding current team const [duplicate] = await db .select() .from(schema.teams) .where( sql`LOWER(${schema.teams.name}) = LOWER(${trimmedName}) AND ${schema.teams.id} != ${id}`, ); if (duplicate) { return reply.status(409).send({ error: "Team name already exists", code: "CONFLICT" }); } const updateFields: Partial = { name: trimmedName }; if (storageQuota !== undefined) updateFields.storageQuota = storageQuota; if (retentionHours !== undefined) updateFields.retentionHours = retentionHours; await db.update(schema.teams).set(updateFields).where(eq(schema.teams.id, id)); return reply.send({ ok: true }); }, ); // DELETE /api/v1/teams/:id — Delete team (admin only) app.delete( "/api/v1/teams/:id", async (request: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) => { const admin = await requirePermission("teams:manage")(request, reply); if (!admin) return; const { id } = request.params; const [team] = await db.select().from(schema.teams).where(eq(schema.teams.id, id)); if (!team) { return reply.status(404).send({ error: "Team not found", code: "NOT_FOUND" }); } // Cannot delete the Default team if (team.name === "Default") { return reply.status(400).send({ error: "Cannot delete the Default team", code: "VALIDATION_ERROR", }); } // Cannot delete a team that has members const [memberCount] = await db .select({ count: sql`COUNT(*)::int` }) .from(schema.users) .where(eq(schema.users.team, id)); if (memberCount && memberCount.count > 0) { return reply.status(400).send({ error: "Cannot delete a team that has members", code: "VALIDATION_ERROR", }); } await db.delete(schema.teams).where(eq(schema.teams.id, id)); return reply.send({ ok: true }); }, ); app.log.info("Teams routes registered"); }