- Created waitlist_signups and waitlist_events tables - Supports email, name, source tracking, and status management - Enables VIP supporter list for Product Hunt launch - Migration 0002_chemical_shocker.sql generated - Fixed brand color in product-hunt-assets-brief.md (#518ac8)
655 lines
19 KiB
TypeScript
655 lines
19 KiB
TypeScript
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,
|
|
} from '../../src/db/schema';
|
|
|
|
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<number> {
|
|
const maxResult = await db
|
|
.select({ maxVersion: sql<number>`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<number> {
|
|
const result = await db
|
|
.select({ maxId: sql<number>`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 }) => {
|
|
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 rows = await ctx.db!
|
|
.select()
|
|
.from(revisions)
|
|
.where(eq(revisions.id, input.id));
|
|
|
|
const revision = rows[0];
|
|
if (!revision) {
|
|
throw new TRPCError({ code: 'NOT_FOUND', message: `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 TRPCError({ code: 'UNAUTHORIZED', message: 'User not authenticated' });
|
|
}
|
|
|
|
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 result = await ctx.db
|
|
.select()
|
|
.from(revisions)
|
|
.where(eq(revisions.id, input.id));
|
|
const revision = result[0];
|
|
if (!revision) {
|
|
throw new TRPCError({ code: 'NOT_FOUND', message: `Revision ${input.id} not found` });
|
|
}
|
|
|
|
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 }) => {
|
|
const result = await ctx.db
|
|
.delete(revisions)
|
|
.where(eq(revisions.id, input.id))
|
|
.returning();
|
|
|
|
if (result.length === 0) {
|
|
throw new TRPCError({ code: 'NOT_FOUND', message: `Revision ${input.id} not found` });
|
|
}
|
|
|
|
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 }) => {
|
|
const revisionResult = await ctx.db
|
|
.select()
|
|
.from(revisions)
|
|
.where(eq(revisions.id, input.revisionId));
|
|
const revision = revisionResult[0];
|
|
if (!revision) {
|
|
throw new TRPCError({ code: 'NOT_FOUND', message: `Revision ${input.revisionId} not found` });
|
|
}
|
|
|
|
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 baseResult = await ctx.db
|
|
.select()
|
|
.from(revisions)
|
|
.where(eq(revisions.id, input.baseRevisionId));
|
|
const baseRevision = baseResult[0];
|
|
|
|
const targetResult = await ctx.db
|
|
.select()
|
|
.from(revisions)
|
|
.where(eq(revisions.id, input.targetRevisionId));
|
|
const targetRevision = targetResult[0];
|
|
|
|
if (!baseRevision) {
|
|
throw new TRPCError({ code: 'NOT_FOUND', message: `Base revision ${input.baseRevisionId} not found` });
|
|
}
|
|
if (!targetRevision) {
|
|
throw new TRPCError({ code: 'NOT_FOUND', message: `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 TRPCError({ code: 'UNAUTHORIZED', message: 'User not authenticated' });
|
|
}
|
|
|
|
const result = await ctx.db
|
|
.select()
|
|
.from(revisions)
|
|
.where(eq(revisions.id, input.revisionId));
|
|
const revision = result[0];
|
|
if (!revision) {
|
|
throw new TRPCError({ code: 'NOT_FOUND', message: `Revision ${input.revisionId} not found` });
|
|
}
|
|
|
|
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 result = await ctx.db
|
|
.select()
|
|
.from(revisions)
|
|
.where(eq(revisions.id, input.revisionId));
|
|
const revision = result[0];
|
|
if (!revision) {
|
|
throw new TRPCError({ code: 'NOT_FOUND', message: `Revision ${input.revisionId} not found` });
|
|
}
|
|
|
|
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' });
|
|
}
|
|
|
|
const targetResult = await ctx.db
|
|
.select()
|
|
.from(revisions)
|
|
.where(eq(revisions.id, input.revisionId));
|
|
const targetRevision = targetResult[0];
|
|
if (!targetRevision) {
|
|
throw new TRPCError({ code: 'NOT_FOUND', message: `Revision ${input.revisionId} not found` });
|
|
}
|
|
|
|
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 }) => {
|
|
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 }) => {
|
|
const scriptRevisions = await ctx.db
|
|
.select()
|
|
.from(revisions)
|
|
.where(eq(revisions.scriptId, input.scriptId));
|
|
|
|
const branchMap = new Map<string, typeof scriptRevisions>();
|
|
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' });
|
|
}
|
|
|
|
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' });
|
|
}
|
|
|
|
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]!;
|
|
}),
|
|
};
|