H1: Add verifyScriptAccess/verifyRevisionAccess to all 14 revisions endpoints H2: Add verifyProjectAccess to listScripts and searchScripts M2: Add cascade delete for projectMembers on project deletion M4: Replace plain Error throws with TRPCError for consistent error handling M5: Use crypto.randomUUID for team ID generation (was Date.now + Math.random) L1: Add 100KB content size limit on revision content L2: Add unique constraint to script slug column L3: Update hasProjectAccess middleware to check project membership
265 lines
8.2 KiB
TypeScript
265 lines
8.2 KiB
TypeScript
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' });
|
|
}
|
|
}
|
|
|
|
async function generateTeamId(): Promise<string> {
|
|
const { randomUUID } = await import('crypto');
|
|
return `team_${randomUUID()}`;
|
|
}
|
|
|
|
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 = await 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<string, any> = { 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 };
|
|
}),
|
|
};
|