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
193 lines
5.8 KiB
TypeScript
193 lines
5.8 KiB
TypeScript
import { protectedProcedure, TRPCError } from './router';
|
|
import { z } from 'zod';
|
|
import { eq, and, like, sql, inArray } from 'drizzle-orm';
|
|
import type { DrizzleDB } from '../../src/db/config/migrations';
|
|
import {
|
|
scripts,
|
|
revisions,
|
|
revisionChanges,
|
|
projects,
|
|
projectMembers,
|
|
} from '../../src/db/schema';
|
|
|
|
function slugify(title: string): string {
|
|
return title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');
|
|
}
|
|
|
|
// H2 fix: verifies user has access to a project (owner or member)
|
|
async function verifyProjectAccess(
|
|
db: DrizzleDB,
|
|
projectId: number,
|
|
userId: number
|
|
) {
|
|
const projectRows = await db
|
|
.select({ id: projects.id, ownerId: projects.ownerId })
|
|
.from(projects)
|
|
.where(eq(projects.id, projectId));
|
|
|
|
const project = projectRows[0];
|
|
if (!project) {
|
|
throw new TRPCError({ code: 'NOT_FOUND', message: `Project ${projectId} not found` });
|
|
}
|
|
|
|
if (project.ownerId === userId) return project;
|
|
|
|
const memberRows = await db
|
|
.select()
|
|
.from(projectMembers)
|
|
.where(and(eq(projectMembers.projectId, projectId), eq(projectMembers.userId, userId)));
|
|
|
|
if (memberRows.length === 0) {
|
|
throw new TRPCError({ code: 'FORBIDDEN', message: `You do not have access to project ${projectId}` });
|
|
}
|
|
|
|
return project;
|
|
}
|
|
|
|
async function verifyScriptOwnership(
|
|
db: DrizzleDB,
|
|
scriptId: number,
|
|
userId: number
|
|
) {
|
|
const scriptRows = await db.select({ id: scripts.id, projectId: scripts.projectId })
|
|
.from(scripts)
|
|
.where(eq(scripts.id, scriptId));
|
|
|
|
const script = scriptRows[0];
|
|
if (!script) {
|
|
throw new TRPCError({ code: 'NOT_FOUND', message: `Script ${scriptId} not found` });
|
|
}
|
|
|
|
await verifyProjectAccess(db, script.projectId, userId);
|
|
|
|
return script;
|
|
}
|
|
|
|
export const scriptsRouter = {
|
|
listScripts: protectedProcedure
|
|
.input(z.object({ projectId: z.number().int().positive() }))
|
|
.query(async ({ input, ctx }) => {
|
|
await verifyProjectAccess(ctx.db!, input.projectId, ctx.userId!);
|
|
|
|
return await ctx.db!.select()
|
|
.from(scripts)
|
|
.where(eq(scripts.projectId, input.projectId))
|
|
.orderBy(scripts.updatedAt);
|
|
}),
|
|
|
|
getScript: protectedProcedure
|
|
.input(z.object({ id: z.number().int().positive() }))
|
|
.query(async ({ input, ctx }) => {
|
|
await verifyScriptOwnership(ctx.db!, input.id, ctx.userId!);
|
|
const rows = await ctx.db!.select()
|
|
.from(scripts)
|
|
.where(eq(scripts.id, input.id));
|
|
const script = rows[0];
|
|
if (!script) {
|
|
throw new TRPCError({ code: 'NOT_FOUND', message: `Script ${input.id} not found` });
|
|
}
|
|
return script;
|
|
}),
|
|
|
|
createScript: protectedProcedure
|
|
.input(z.object({
|
|
title: z.string().min(1).max(255),
|
|
projectId: z.number().int().positive(),
|
|
genre: z.string().optional(),
|
|
logline: z.string().optional(),
|
|
status: z.enum(['draft', 'revision', 'final', 'published']).optional(),
|
|
}))
|
|
.mutation(async ({ input, ctx }) => {
|
|
await verifyProjectAccess(ctx.db!, input.projectId, ctx.userId!);
|
|
|
|
const result = await ctx.db!.insert(scripts)
|
|
.values({
|
|
title: input.title,
|
|
slug: slugify(input.title),
|
|
projectId: input.projectId,
|
|
genre: input.genre ?? null,
|
|
logline: input.logline ?? null,
|
|
status: input.status ?? 'draft',
|
|
})
|
|
.returning();
|
|
return result[0];
|
|
}),
|
|
|
|
updateScript: protectedProcedure
|
|
.input(z.object({
|
|
id: z.number().int().positive(),
|
|
title: z.string().min(1).max(255).optional(),
|
|
genre: z.string().optional(),
|
|
logline: z.string().optional(),
|
|
status: z.enum(['draft', 'revision', 'final', 'published']).optional(),
|
|
}))
|
|
.mutation(async ({ input, ctx }) => {
|
|
await verifyScriptOwnership(ctx.db!, input.id, ctx.userId!);
|
|
|
|
const updateData: Record<string, any> = { updatedAt: new Date() };
|
|
if (input.title !== undefined) {
|
|
updateData.title = input.title;
|
|
updateData.slug = slugify(input.title);
|
|
}
|
|
if (input.genre !== undefined) updateData.genre = input.genre ?? null;
|
|
if (input.logline !== undefined) updateData.logline = input.logline ?? null;
|
|
if (input.status !== undefined) updateData.status = input.status;
|
|
|
|
const result = await ctx.db!.update(scripts)
|
|
.set(updateData)
|
|
.where(eq(scripts.id, input.id))
|
|
.returning();
|
|
return result[0];
|
|
}),
|
|
|
|
deleteScript: protectedProcedure
|
|
.input(z.object({ id: z.number().int().positive() }))
|
|
.mutation(async ({ input, ctx }) => {
|
|
await verifyScriptOwnership(ctx.db!, input.id, ctx.userId!);
|
|
|
|
// Get revision IDs for this script
|
|
const scriptRevisions = await ctx.db!.select({ id: revisions.id })
|
|
.from(revisions)
|
|
.where(eq(revisions.scriptId, input.id));
|
|
|
|
// Delete revision changes for each revision
|
|
for (const rev of scriptRevisions) {
|
|
await ctx.db!.delete(revisionChanges)
|
|
.where(eq(revisionChanges.revisionId, rev.id));
|
|
}
|
|
|
|
// Delete revisions
|
|
await ctx.db!.delete(revisions)
|
|
.where(eq(revisions.scriptId, input.id));
|
|
|
|
// Delete script
|
|
await ctx.db!.delete(scripts)
|
|
.where(eq(scripts.id, input.id));
|
|
|
|
return { success: true };
|
|
}),
|
|
|
|
searchScripts: protectedProcedure
|
|
.input(z.object({
|
|
projectId: z.number().int().positive(),
|
|
query: z.string().optional(),
|
|
}))
|
|
.query(async ({ input, ctx }) => {
|
|
await verifyProjectAccess(ctx.db!, input.projectId, ctx.userId!);
|
|
|
|
const conditions: any[] = [eq(scripts.projectId, input.projectId)];
|
|
|
|
if (input.query) {
|
|
const q = `%${input.query.toLowerCase()}%`;
|
|
conditions.push(
|
|
like(sql`LOWER(${scripts.title})`, q),
|
|
);
|
|
}
|
|
|
|
return await ctx.db!.select()
|
|
.from(scripts)
|
|
.where(and(...conditions))
|
|
.orderBy(scripts.title);
|
|
}),
|
|
};
|