import { protectedProcedure, TRPCError } from './router'; import { z } from 'zod'; import { eq, and, asc } from 'drizzle-orm'; import type { DrizzleDB } from '../../src/db/config/migrations'; import { teams, teamMembers } from '../../src/db/schema'; async function verifyTeamOwnership( db: DrizzleDB, teamId: string, userId: number ) { const teamRows = await db.select({ id: teams.id, ownerId: teams.ownerId }) .from(teams) .where(eq(teams.id, teamId)); const team = teamRows[0]; if (!team) { throw new TRPCError({ code: 'NOT_FOUND', message: `Team ${teamId} not found` }); } if (team.ownerId !== userId) { const memberRows = await db.select() .from(teamMembers) .where(and(eq(teamMembers.teamId, teamId), eq(teamMembers.userId, userId))); if (memberRows.length === 0) { throw new TRPCError({ code: 'FORBIDDEN', message: `You do not have access to team ${teamId}` }); } } return team; } async function verifyTeamRole( db: DrizzleDB, teamId: string, userId: number, allowedRoles: string[] ) { await verifyTeamOwnership(db, teamId, userId); const teamRows = await db.select({ id: teams.id, ownerId: teams.ownerId }) .from(teams) .where(eq(teams.id, teamId)); const team = teamRows[0]; if (!team) return; if (team.ownerId === userId) return; const memberRows = await db.select() .from(teamMembers) .where(and(eq(teamMembers.teamId, teamId), eq(teamMembers.userId, userId))); const member = memberRows[0]; if (!member || !allowedRoles.includes(member.role)) { throw new TRPCError({ code: 'FORBIDDEN', message: 'Insufficient permissions' }); } } function generateTeamId(): string { return `team_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`; } export const teamRouter = { // Team CRUD listTeams: protectedProcedure.query(async ({ ctx }) => { const owned = await ctx.db!.select() .from(teams) .where(eq(teams.ownerId, ctx.userId!)) .orderBy(asc(teams.createdAt)); const memberRows = await ctx.db!.select({ teamId: teamMembers.teamId }) .from(teamMembers) .where(eq(teamMembers.userId, ctx.userId!)); const memberTeamIds = new Set(memberRows.map((r) => r.teamId)); const memberTeams: typeof owned = []; for (const tid of memberTeamIds) { const row = await ctx.db!.select() .from(teams) .where(eq(teams.id, tid)) .then((r) => r[0]); if (row) memberTeams.push(row); } const all = [...owned, ...memberTeams]; const seen = new Set(all.map((t) => t.id)); return all.filter((t) => seen.has(t.id)); }), getTeam: protectedProcedure .input(z.object({ id: z.string().min(1) })) .query(async ({ input, ctx }) => { await verifyTeamOwnership(ctx.db!, input.id, ctx.userId!); const rows = await ctx.db!.select() .from(teams) .where(eq(teams.id, input.id)); return rows[0]; }), createTeam: protectedProcedure .input(z.object({ name: z.string().min(1).max(255), })) .mutation(async ({ input, ctx }) => { const teamId = generateTeamId(); const result = await ctx.db!.insert(teams) .values({ id: teamId, name: input.name, ownerId: ctx.userId!, }) .returning(); const team = result[0]; if (!team) { throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Failed to create team' }); } await ctx.db!.insert(teamMembers) .values({ teamId: team.id, userId: ctx.userId!, role: 'owner', }); return team; }), updateTeam: protectedProcedure .input(z.object({ id: z.string().min(1), name: z.string().min(1).max(255).optional(), })) .mutation(async ({ input, ctx }) => { await verifyTeamRole(ctx.db!, input.id, ctx.userId!, ['owner', 'admin']); const updateData: Record = { updatedAt: new Date() }; if (input.name !== undefined) updateData.name = input.name; const result = await ctx.db!.update(teams) .set(updateData) .where(eq(teams.id, input.id)) .returning(); return result[0]; }), deleteTeam: protectedProcedure .input(z.object({ id: z.string().min(1) })) .mutation(async ({ input, ctx }) => { await verifyTeamOwnership(ctx.db!, input.id, ctx.userId!); const teamRows = await ctx.db!.select({ id: teams.id, ownerId: teams.ownerId }) .from(teams) .where(eq(teams.id, input.id)); if (teamRows[0]?.ownerId !== ctx.userId!) { throw new TRPCError({ code: 'FORBIDDEN', message: 'Only the owner can delete a team' }); } await ctx.db!.delete(teamMembers) .where(eq(teamMembers.teamId, input.id)); await ctx.db!.delete(teams) .where(eq(teams.id, input.id)); return { success: true }; }), // Team member management listMembers: protectedProcedure .input(z.object({ teamId: z.string().min(1) })) .query(async ({ input, ctx }) => { await verifyTeamOwnership(ctx.db!, input.teamId, ctx.userId!); return await ctx.db!.select() .from(teamMembers) .where(eq(teamMembers.teamId, input.teamId)) .orderBy(asc(teamMembers.joinedAt)); }), addMember: protectedProcedure .input(z.object({ teamId: z.string().min(1), userId: z.number().int().positive(), role: z.enum(['owner', 'admin', 'editor', 'viewer']).default('editor'), })) .mutation(async ({ input, ctx }) => { await verifyTeamRole(ctx.db!, input.teamId, ctx.userId!, ['owner', 'admin']); const existing = await ctx.db!.select() .from(teamMembers) .where(and(eq(teamMembers.teamId, input.teamId), eq(teamMembers.userId, input.userId))); if (existing.length > 0) { throw new TRPCError({ code: 'CONFLICT', message: 'User is already a member of this team' }); } const result = await ctx.db!.insert(teamMembers) .values({ teamId: input.teamId, userId: input.userId, role: input.role, }) .returning(); return result[0]; }), updateMemberRole: protectedProcedure .input(z.object({ teamId: z.string().min(1), userId: z.number().int().positive(), role: z.enum(['owner', 'admin', 'editor', 'viewer']), })) .mutation(async ({ input, ctx }) => { await verifyTeamRole(ctx.db!, input.teamId, ctx.userId!, ['owner']); const result = await ctx.db!.update(teamMembers) .set({ role: input.role }) .where(and(eq(teamMembers.teamId, input.teamId), eq(teamMembers.userId, input.userId))) .returning(); return result[0]; }), removeMember: protectedProcedure .input(z.object({ teamId: z.string().min(1), userId: z.number().int().positive(), })) .mutation(async ({ input, ctx }) => { await verifyTeamRole(ctx.db!, input.teamId, ctx.userId!, ['owner', 'admin']); if (input.userId === ctx.userId!) { throw new TRPCError({ code: 'FORBIDDEN', message: 'You cannot remove yourself from this team' }); } const memberRows = await ctx.db!.select() .from(teamMembers) .where(and(eq(teamMembers.teamId, input.teamId), eq(teamMembers.userId, input.userId))); if (memberRows[0]?.role === 'owner') { throw new TRPCError({ code: 'FORBIDDEN', message: 'Cannot remove the team owner' }); } await ctx.db!.delete(teamMembers) .where(and(eq(teamMembers.teamId, input.teamId), eq(teamMembers.userId, input.userId))); return { success: true }; }), leaveTeam: protectedProcedure .input(z.object({ teamId: z.string().min(1) })) .mutation(async ({ input, ctx }) => { const memberRows = await ctx.db!.select() .from(teamMembers) .where(and(eq(teamMembers.teamId, input.teamId), eq(teamMembers.userId, ctx.userId!))); if (memberRows[0]?.role === 'owner') { throw new TRPCError({ code: 'FORBIDDEN', message: 'Owner cannot leave the team. Transfer ownership first.' }); } await ctx.db!.delete(teamMembers) .where(and(eq(teamMembers.teamId, input.teamId), eq(teamMembers.userId, ctx.userId!))); return { success: true }; }), };