import { protectedProcedure } from './router'; import { z } from 'zod'; // In-memory storage const revisions: Map = new Map(); const revisionChanges: Map = new Map(); let revisionIdCounter = 0; let changeIdCounter = 0; function getNextRevisionId(): number { return ++revisionIdCounter; } function getNextChangeId(): number { return ++changeIdCounter; } function computeDiffForContent( oldContent: string, newContent: string, revisionId: number ): Map { const oldLines = oldContent.split('\n'); const newLines = newContent.split('\n'); const changes = new Map(); let sceneCounter = 0; const linesPerPage = 55; 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; 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++; } const change = { id: getNextChangeId(), 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), createdAt: new Date(), }; changes.set(change.id, change); revisionChanges.set(change.id, change); } return changes; } function getLatestVersionForScript(scriptId: number, branchName: string): number { let maxVersion = 0; for (const rev of revisions.values()) { if (rev.scriptId === scriptId && rev.branchName === branchName) { if (rev.versionNumber > maxVersion) { maxVersion = rev.versionNumber; } } } return maxVersion; } // Export reset function for testing export function resetInMemoryState() { revisions.clear(); revisionChanges.clear(); revisionIdCounter = 0; changeIdCounter = 0; } export const revisionsRouter = { listRevisions: protectedProcedure .input(z.object({ scriptId: z.number().int().positive(), branchName: z.string().optional(), })) .query(async ({ input }) => { const results = Array.from(revisions.values()) .filter(r => r.scriptId === input.scriptId) .filter(r => !input.branchName || r.branchName === input.branchName) .sort((a, b) => b.versionNumber - a.versionNumber); return results; }), getRevision: protectedProcedure .input(z.object({ id: z.number().int().positive(), })) .query(async ({ input }) => { const revision = revisions.get(input.id); if (!revision) { throw new Error(`Revision ${input.id} not found`); } 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(), branchName: z.string().default('main'), parentRevisionId: z.number().int().positive().optional(), })) .mutation(async ({ input, ctx }) => { if (!ctx.userId) { throw new Error('User not authenticated'); } const nextVersion = getLatestVersionForScript( input.scriptId, input.branchName ) + 1; const revision = { id: getNextRevisionId(), 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' as const, reviewedById: null, reviewedAt: null, createdAt: new Date(), updatedAt: new Date(), }; revisions.set(revision.id, revision); if (input.parentRevisionId) { const parent = revisions.get(input.parentRevisionId); if (parent) { computeDiffForContent(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 }) => { const revision = revisions.get(input.id); if (!revision) { throw new Error(`Revision ${input.id} not found`); } const updated = { ...revision, ...(input.title && { title: input.title }), ...(input.summary !== undefined && { summary: input.summary }), ...(input.content !== undefined && { content: input.content }), ...(input.status && { status: input.status }), updatedAt: new Date(), }; revisions.set(updated.id, updated); return updated; }), deleteRevision: protectedProcedure .input(z.object({ id: z.number().int().positive(), })) .mutation(async ({ input }) => { const deleted = revisions.delete(input.id); if (!deleted) { throw new Error(`Revision ${input.id} not found`); } for (const [changeId, change] of revisionChanges) { if (change.revisionId === input.id) { revisionChanges.delete(changeId); } } return { success: true }; }), getRevisionChanges: protectedProcedure .input(z.object({ revisionId: z.number().int().positive(), })) .query(async ({ input }) => { const revision = revisions.get(input.revisionId); if (!revision) { throw new Error(`Revision ${input.revisionId} not found`); } const changes = Array.from(revisionChanges.values()) .filter(c => c.revisionId === input.revisionId) .sort((a, b) => (a.lineNumber || 0) - (b.lineNumber || 0)); return changes; }), compareRevisions: protectedProcedure .input(z.object({ baseRevisionId: z.number().int().positive(), targetRevisionId: z.number().int().positive(), })) .query(async ({ input }) => { const baseRevision = revisions.get(input.baseRevisionId); const targetRevision = revisions.get(input.targetRevisionId); if (!baseRevision) { throw new Error(`Base revision ${input.baseRevisionId} not found`); } if (!targetRevision) { throw new Error(`Target revision ${input.targetRevisionId} not found`); } 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 Error('User not authenticated'); } const revision = revisions.get(input.revisionId); if (!revision) { throw new Error(`Revision ${input.revisionId} not found`); } const updated = { ...revision, status: 'accepted' as const, reviewedById: ctx.userId, reviewedAt: new Date(), updatedAt: new Date(), }; revisions.set(updated.id, updated); return updated; }), 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 Error('User not authenticated'); } const revision = revisions.get(input.revisionId); if (!revision) { throw new Error(`Revision ${input.revisionId} not found`); } const updated = { ...revision, status: 'rejected' as const, reviewedById: ctx.userId, reviewedAt: new Date(), summary: input.reason ? (revision.summary || '') + '\n[Rejected: ' + input.reason + ']' : revision.summary, updatedAt: new Date(), }; revisions.set(updated.id, updated); return updated; }), rollbackToRevision: protectedProcedure .input(z.object({ scriptId: z.number().int().positive(), revisionId: z.number().int().positive(), })) .mutation(async ({ input, ctx }) => { if (!ctx.userId) { throw new Error('User not authenticated'); } const targetRevision = revisions.get(input.revisionId); if (!targetRevision) { throw new Error(`Revision ${input.revisionId} not found`); } if (targetRevision.scriptId !== input.scriptId) { throw new Error('Revision does not belong to the specified script'); } const nextVersion = getLatestVersionForScript( input.scriptId, targetRevision.branchName ) + 1; const rollbackRevision = { id: getNextRevisionId(), 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' as const, reviewedById: null, reviewedAt: null, createdAt: new Date(), updatedAt: new Date(), }; revisions.set(rollbackRevision.id, rollbackRevision); return rollbackRevision; }), getTimeline: protectedProcedure .input(z.object({ scriptId: z.number().int().positive(), })) .query(async ({ input }) => { const scriptRevisions = Array.from(revisions.values()) .filter(r => r.scriptId === input.scriptId) .sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime()); const timeline = scriptRevisions.map(rev => { const changes = Array.from(revisionChanges.values()) .filter(c => c.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 }) => { const scriptRevisions = Array.from(revisions.values()) .filter(r => r.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 Error('User not authenticated'); } const existing = Array.from(revisions.values()) .some(r => r.scriptId === input.scriptId && r.branchName === input.branchName); if (existing) { throw new Error(`Branch '${input.branchName}' already exists for this script`); } let sourceContent = ''; let parentRevisionId: number | null = null; if (input.fromRevisionId) { const source = revisions.get(input.fromRevisionId); if (!source) { throw new Error(`Source revision ${input.fromRevisionId} not found`); } sourceContent = source.content; parentRevisionId = source.id; } else { const mainRevisions = Array.from(revisions.values()) .filter(r => r.scriptId === input.scriptId && r.branchName === 'main') .sort((a, b) => b.versionNumber - a.versionNumber); if (mainRevisions.length > 0) { sourceContent = mainRevisions[0]!.content; parentRevisionId = mainRevisions[0]!.id; } } const branchRevision = { id: getNextRevisionId(), scriptId: input.scriptId, versionNumber: 1, branchName: input.branchName, parentRevisionId, title: `Branch: ${input.branchName}`, summary: null, content: sourceContent, authorId: ctx.userId, status: 'draft' as const, reviewedById: null, reviewedAt: null, createdAt: new Date(), updatedAt: new Date(), }; revisions.set(branchRevision.id, branchRevision); return branchRevision; }), 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 Error('User not authenticated'); } if (input.sourceBranch === input.targetBranch) { throw new Error('Cannot merge a branch into itself'); } const sourceRevisions = Array.from(revisions.values()) .filter(r => r.scriptId === input.scriptId && r.branchName === input.sourceBranch) .sort((a, b) => b.versionNumber - a.versionNumber); if (sourceRevisions.length === 0) { throw new Error(`Source branch '${input.sourceBranch}' has no revisions`); } const sourceContent = sourceRevisions[0]!.content; const nextVersion = getLatestVersionForScript( input.scriptId, input.targetBranch ) + 1; const mergeRevision = { id: getNextRevisionId(), 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' as const, reviewedById: null, reviewedAt: null, createdAt: new Date(), updatedAt: new Date(), }; revisions.set(mergeRevision.id, mergeRevision); return mergeRevision; }), };