import { protectedProcedure, TRPCError } from './router'; import { z } from 'zod'; import { eq, and, or, like, sql, desc, asc } from 'drizzle-orm'; import type { DrizzleDB } from '../../src/db/config/migrations'; import { revisions, revisionChanges, scripts, projects, projectMembers, } from '../../src/db/schema'; // H1 fix: verifies user has access to the project owning the script a revision belongs to async function verifyScriptAccess( 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` }); } const projectRows = await db .select({ id: projects.id, ownerId: projects.ownerId }) .from(projects) .where(eq(projects.id, script.projectId)); const project = projectRows[0]; if (!project) { throw new TRPCError({ code: 'NOT_FOUND', message: `Project ${script.projectId} not found` }); } if (project.ownerId === userId) return { script, project }; const memberRows = await db .select() .from(projectMembers) .where(and(eq(projectMembers.projectId, script.projectId), eq(projectMembers.userId, userId))); if (memberRows.length === 0) { throw new TRPCError({ code: 'FORBIDDEN', message: `You do not have access to script ${scriptId}` }); } return { script, project }; } // Resolves revision → script → project and verifies user access async function verifyRevisionAccess( db: DrizzleDB, revisionId: number, userId: number ) { const revisionRows = await db .select() .from(revisions) .where(eq(revisions.id, revisionId)); const revision = revisionRows[0]; if (!revision) { throw new TRPCError({ code: 'NOT_FOUND', message: `Revision ${revisionId} not found` }); } const { script, project } = await verifyScriptAccess(db, revision.scriptId, userId); return { revision, script, project }; } function computeDiffForContent( db: DrizzleDB, oldContent: string, newContent: string, revisionId: number ) { const oldLines = oldContent.split('\n'); const newLines = newContent.split('\n'); const linesPerPage = 55; const maxLen = Math.max(oldLines.length, newLines.length); let sceneCounter = 0; const changesToInsert = []; for (let i = 0; i < maxLen; i++) { const oldLine = oldLines[i]; const newLine = newLines[i]; if (oldLine === newLine) continue; let changeType: 'addition' | 'deletion' | 'modification'; if (!oldLine && newLine) { changeType = 'addition'; } else if (oldLine && !newLine) { changeType = 'deletion'; } else { changeType = 'modification'; } if (newLine?.trim().toUpperCase().startsWith('INT.') || newLine?.trim().toUpperCase().startsWith('EXT.')) { sceneCounter++; } changesToInsert.push({ revisionId, changeType, elementType: null, oldContent: changeType !== 'addition' ? oldLine || null : null, newContent: changeType !== 'deletion' ? newLine || null : null, sceneNumber: sceneCounter || null, lineNumber: i + 1, pageNumber: Math.ceil((i + 1) / linesPerPage), }); } if (changesToInsert.length > 0) { return db.insert(revisionChanges).values(changesToInsert).returning(); } return []; } async function getLatestVersionForScript( db: DrizzleDB, scriptId: number, branchName: string ): Promise { const maxResult = await db .select({ maxVersion: sql`MAX(${revisions.versionNumber})` }) .from(revisions) .where(and(eq(revisions.scriptId, scriptId), eq(revisions.branchName, branchName))); return maxResult[0]?.maxVersion ?? 0; } // Export reset function for testing export async function resetInMemoryState(db: DrizzleDB) { await db.delete(revisionChanges); await db.delete(revisions); } // Helper to get next revision ID async function getNextRevisionId(db: DrizzleDB): Promise { const result = await db .select({ maxId: sql`MAX(${revisions.id})` }) .from(revisions); return (result[0]?.maxId ?? 0) + 1; } export const revisionsRouter = { listRevisions: protectedProcedure .input(z.object({ scriptId: z.number().int().positive(), branchName: z.string().optional(), })) .query(async ({ input, ctx }) => { await verifyScriptAccess(ctx.db!, input.scriptId, ctx.userId!); const conditions = [eq(revisions.scriptId, input.scriptId)]; if (input.branchName) { conditions.push(eq(revisions.branchName, input.branchName)); } const results = await ctx.db! .select() .from(revisions) .where(and(...conditions)) .orderBy(desc(revisions.versionNumber)); return results; }), getRevision: protectedProcedure .input(z.object({ id: z.number().int().positive(), })) .query(async ({ input, ctx }) => { const { revision } = await verifyRevisionAccess(ctx.db!, input.id, ctx.userId!); return revision; }), createRevision: protectedProcedure .input(z.object({ scriptId: z.number().int().positive(), title: z.string().min(1).max(255), summary: z.string().max(2000).optional(), content: z.string().max(100000), branchName: z.string().default('main'), parentRevisionId: z.number().int().positive().optional(), })) .mutation(async ({ input, ctx }) => { if (!ctx.userId) { throw new TRPCError({ code: 'UNAUTHORIZED', message: 'User not authenticated' }); } await verifyScriptAccess(ctx.db!, input.scriptId, ctx.userId!); const nextVersion = await getLatestVersionForScript( ctx.db, input.scriptId, input.branchName ) + 1; const nextId = await getNextRevisionId(ctx.db); const result = await ctx.db .insert(revisions) .values({ id: nextId, scriptId: input.scriptId, versionNumber: nextVersion, branchName: input.branchName, parentRevisionId: input.parentRevisionId || null, title: input.title, summary: input.summary || null, content: input.content, authorId: ctx.userId, status: 'draft', reviewedById: null, reviewedAt: null, createdAt: new Date(), updatedAt: new Date(), }) .returning(); const revision = result[0]!; if (input.parentRevisionId) { const parentResult = await ctx.db .select() .from(revisions) .where(eq(revisions.id, input.parentRevisionId)); const parent = parentResult[0]; if (parent) { await computeDiffForContent(ctx.db, parent.content, input.content, revision.id); } } return revision; }), updateRevision: protectedProcedure .input(z.object({ id: z.number().int().positive(), title: z.string().min(1).max(255).optional(), summary: z.string().max(2000).optional(), content: z.string().optional(), status: z.enum(['draft', 'pending_review', 'accepted', 'rejected']).optional(), })) .mutation(async ({ input, ctx }) => { const { revision } = await verifyRevisionAccess(ctx.db!, input.id, ctx.userId!); const updated = await ctx.db .update(revisions) .set({ title: input.title ?? revision.title, summary: input.summary ?? revision.summary, content: input.content ?? revision.content, status: input.status ?? revision.status, updatedAt: new Date(), }) .where(eq(revisions.id, input.id)) .returning(); return updated[0]!; }), deleteRevision: protectedProcedure .input(z.object({ id: z.number().int().positive(), })) .mutation(async ({ input, ctx }) => { await verifyRevisionAccess(ctx.db!, input.id, ctx.userId!); const result = await ctx.db .delete(revisions) .where(eq(revisions.id, input.id)) .returning(); await ctx.db .delete(revisionChanges) .where(eq(revisionChanges.revisionId, input.id)); return { success: true }; }), getRevisionChanges: protectedProcedure .input(z.object({ revisionId: z.number().int().positive(), })) .query(async ({ input, ctx }) => { await verifyRevisionAccess(ctx.db!, input.revisionId, ctx.userId!); const changes = await ctx.db .select() .from(revisionChanges) .where(eq(revisionChanges.revisionId, input.revisionId)) .orderBy(asc(revisionChanges.lineNumber)); return changes; }), compareRevisions: protectedProcedure .input(z.object({ baseRevisionId: z.number().int().positive(), targetRevisionId: z.number().int().positive(), })) .query(async ({ input, ctx }) => { const { revision: baseRevision } = await verifyRevisionAccess(ctx.db!, input.baseRevisionId, ctx.userId!); const { revision: targetRevision } = await verifyRevisionAccess(ctx.db!, input.targetRevisionId, ctx.userId!); const oldLines = baseRevision.content.split('\n'); const newLines = targetRevision.content.split('\n'); let additions = 0; let deletions = 0; let modifications = 0; const maxLen = Math.max(oldLines.length, newLines.length); for (let i = 0; i < maxLen; i++) { const oldLine = oldLines[i]; const newLine = newLines[i]; if (oldLine === newLine) continue; if (!oldLine && newLine) { additions++; } else if (oldLine && !newLine) { deletions++; } else { modifications++; } } return { baseRevision, targetRevision, diff: { additions, deletions, modifications, }, }; }), acceptRevision: protectedProcedure .input(z.object({ revisionId: z.number().int().positive(), })) .mutation(async ({ input, ctx }) => { if (!ctx.userId) { throw new TRPCError({ code: 'UNAUTHORIZED', message: 'User not authenticated' }); } await verifyRevisionAccess(ctx.db!, input.revisionId, ctx.userId!); const updated = await ctx.db .update(revisions) .set({ status: 'accepted', reviewedById: ctx.userId, reviewedAt: new Date(), updatedAt: new Date(), }) .where(eq(revisions.id, input.revisionId)) .returning(); return updated[0]!; }), rejectRevision: protectedProcedure .input(z.object({ revisionId: z.number().int().positive(), reason: z.string().max(1000).optional(), })) .mutation(async ({ input, ctx }) => { if (!ctx.userId) { throw new TRPCError({ code: 'UNAUTHORIZED', message: 'User not authenticated' }); } const { revision } = await verifyRevisionAccess(ctx.db!, input.revisionId, ctx.userId!); const newSummary = input.reason ? (revision.summary || '') + '\n[Rejected: ' + input.reason + ']' : revision.summary; const updated = await ctx.db .update(revisions) .set({ status: 'rejected', reviewedById: ctx.userId, reviewedAt: new Date(), summary: newSummary, updatedAt: new Date(), }) .where(eq(revisions.id, input.revisionId)) .returning(); return updated[0]!; }), rollbackToRevision: protectedProcedure .input(z.object({ scriptId: z.number().int().positive(), revisionId: z.number().int().positive(), })) .mutation(async ({ input, ctx }) => { if (!ctx.userId) { throw new TRPCError({ code: 'UNAUTHORIZED', message: 'User not authenticated' }); } await verifyScriptAccess(ctx.db!, input.scriptId, ctx.userId!); const { revision: targetRevision } = await verifyRevisionAccess(ctx.db!, input.revisionId, ctx.userId!); if (targetRevision.scriptId !== input.scriptId) { throw new TRPCError({ code: 'BAD_REQUEST', message: 'Revision does not belong to the specified script' }); } const nextVersion = await getLatestVersionForScript( ctx.db, input.scriptId, targetRevision.branchName ) + 1; const nextId = await getNextRevisionId(ctx.db); const rollbackRevision = await ctx.db .insert(revisions) .values({ id: nextId, scriptId: input.scriptId, versionNumber: nextVersion, branchName: targetRevision.branchName, parentRevisionId: targetRevision.id, title: `Rollback to v${targetRevision.versionNumber}: ${targetRevision.title}`, summary: `Rolled back to revision ${targetRevision.id}`, content: targetRevision.content, authorId: ctx.userId, status: 'draft', reviewedById: null, reviewedAt: null, createdAt: new Date(), updatedAt: new Date(), }) .returning(); return rollbackRevision[0]!; }), getTimeline: protectedProcedure .input(z.object({ scriptId: z.number().int().positive(), })) .query(async ({ input, ctx }) => { await verifyScriptAccess(ctx.db!, input.scriptId, ctx.userId!); const scriptRevisions = await ctx.db .select() .from(revisions) .where(eq(revisions.scriptId, input.scriptId)) .orderBy(asc(revisions.createdAt)); const timeline = await Promise.all( scriptRevisions.map(async (rev) => { const changes = await ctx.db .select() .from(revisionChanges) .where(eq(revisionChanges.revisionId, rev.id)); const additions = changes.filter(c => c.changeType === 'addition').length; const deletions = changes.filter(c => c.changeType === 'deletion').length; const modifications = changes.filter(c => c.changeType === 'modification').length; return { revision: rev, changeCount: changes.length, additions, deletions, modifications, }; }) ); return timeline; }), getBranches: protectedProcedure .input(z.object({ scriptId: z.number().int().positive(), })) .query(async ({ input, ctx }) => { await verifyScriptAccess(ctx.db!, input.scriptId, ctx.userId!); const scriptRevisions = await ctx.db .select() .from(revisions) .where(eq(revisions.scriptId, input.scriptId)); const branchMap = new Map(); for (const rev of scriptRevisions) { if (!branchMap.has(rev.branchName)) { branchMap.set(rev.branchName, []); } branchMap.get(rev.branchName)!.push(rev); } const branches = Array.from(branchMap.entries()).map(([branchName, revs]) => { const sorted = revs.sort((a, b) => b.versionNumber - a.versionNumber); const latest = sorted[0]!; return { branchName, revisionCount: revs.length, latestVersion: latest.versionNumber, latestRevision: latest, }; }); return branches; }), createBranch: protectedProcedure .input(z.object({ scriptId: z.number().int().positive(), branchName: z.string().min(1), fromRevisionId: z.number().int().positive().optional(), })) .mutation(async ({ input, ctx }) => { if (!ctx.userId) { throw new TRPCError({ code: 'UNAUTHORIZED', message: 'User not authenticated' }); } await verifyScriptAccess(ctx.db!, input.scriptId, ctx.userId!); const existingResult = await ctx.db .select({ id: revisions.id }) .from(revisions) .where(and( eq(revisions.scriptId, input.scriptId), eq(revisions.branchName, input.branchName) )); if (existingResult.length > 0) { throw new TRPCError({ code: 'CONFLICT', message: `Branch '${input.branchName}' already exists for this script` }); } let sourceContent = ''; let parentRevisionId: number | null = null; if (input.fromRevisionId) { const sourceResult = await ctx.db .select() .from(revisions) .where(eq(revisions.id, input.fromRevisionId)); const source = sourceResult[0]; if (!source) { throw new TRPCError({ code: 'NOT_FOUND', message: `Source revision ${input.fromRevisionId} not found` }); } sourceContent = source.content; parentRevisionId = source.id; } else { const mainRevisions = await ctx.db .select() .from(revisions) .where(and( eq(revisions.scriptId, input.scriptId), eq(revisions.branchName, 'main') )) .orderBy(desc(revisions.versionNumber)) .limit(1); if (mainRevisions.length > 0) { sourceContent = mainRevisions[0]!.content; parentRevisionId = mainRevisions[0]!.id; } } const nextId = await getNextRevisionId(ctx.db); const branchRevision = await ctx.db .insert(revisions) .values({ id: nextId, scriptId: input.scriptId, versionNumber: 1, branchName: input.branchName, parentRevisionId, title: `Branch: ${input.branchName}`, summary: null, content: sourceContent, authorId: ctx.userId, status: 'draft', reviewedById: null, reviewedAt: null, createdAt: new Date(), updatedAt: new Date(), }) .returning(); return branchRevision[0]!; }), mergeBranch: protectedProcedure .input(z.object({ scriptId: z.number().int().positive(), sourceBranch: z.string(), targetBranch: z.string(), })) .mutation(async ({ input, ctx }) => { if (!ctx.userId) { throw new TRPCError({ code: 'UNAUTHORIZED', message: 'User not authenticated' }); } await verifyScriptAccess(ctx.db!, input.scriptId, ctx.userId!); if (input.sourceBranch === input.targetBranch) { throw new TRPCError({ code: 'BAD_REQUEST', message: 'Cannot merge a branch into itself' }); } const sourceRevisions = await ctx.db .select() .from(revisions) .where(and( eq(revisions.scriptId, input.scriptId), eq(revisions.branchName, input.sourceBranch) )) .orderBy(desc(revisions.versionNumber)) .limit(1); if (sourceRevisions.length === 0) { throw new TRPCError({ code: 'NOT_FOUND', message: `Source branch '${input.sourceBranch}' has no revisions` }); } const sourceContent = sourceRevisions[0]!.content; const nextVersion = await getLatestVersionForScript( ctx.db, input.scriptId, input.targetBranch ) + 1; const nextId = await getNextRevisionId(ctx.db); const mergeRevision = await ctx.db .insert(revisions) .values({ id: nextId, scriptId: input.scriptId, versionNumber: nextVersion, branchName: input.targetBranch, parentRevisionId: null, title: `Merge from '${input.sourceBranch}'`, summary: `Merged ${input.sourceBranch} into ${input.targetBranch}`, content: sourceContent, authorId: ctx.userId, status: 'draft', reviewedById: null, reviewedAt: null, createdAt: new Date(), updatedAt: new Date(), }) .returning(); return mergeRevision[0]!; }), };