Add waitlist schema for marketing (FRE-635)

- 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)
This commit is contained in:
2026-04-26 06:21:20 -04:00
parent ce1ba395c7
commit 67c3881dcf
65 changed files with 11909 additions and 382 deletions

View File

@@ -1,58 +1,29 @@
import { createHTTPServer } from '@trpc/server/adapters/standalone';
import { projectRouter } from './project-router';
import { revisionsRouter } from './revisions-router';
import { scriptsRouter } from './scripts-router';
import type { TRPCContext } from './types';
import type { TRPCError } from '@trpc/server';
import { t } from './router';
import { drizzle } from 'drizzle-orm/better-sqlite3';
import Database from 'better-sqlite3';
import { projects, characters, scenes, characterRelationships, sceneCharacters } from '../../src/db/schema';
import { verifyToken } from '@clerk/backend';
// App router combining all routers
export const appRouter = t.router({
project: projectRouter,
revisions: revisionsRouter,
scripts: scriptsRouter,
} as const);
export type AppRouter = typeof appRouter;
// Database instance (shared for now - should come from config)
let dbInstance: ReturnType<typeof drizzle> | null = null;
function getDb() {
if (dbInstance) return dbInstance;
const sqlite = new Database('./data/frenocorp.db');
dbInstance = drizzle(sqlite);
return dbInstance;
}
// Create tRPC HTTP server
// Create tRPC HTTP server - db is loaded lazily to avoid requiring Turso env vars at import time
export function createTRPCServer(port: number = 8080) {
const server = createHTTPServer({
router: appRouter,
createContext: async ({ req }): Promise<TRPCContext> => {
const authHeader = req.headers.authorization;
let userId: number | undefined = undefined;
if (authHeader && authHeader.startsWith('Bearer ')) {
const token = authHeader.substring(7);
try {
const clerkSecretKey = process.env.CLERK_SECRET_KEY;
if (!clerkSecretKey) {
console.warn('CLERK_SECRET_KEY not set, skipping token verification');
} else {
const payload = await verifyToken(token, { secretKey: clerkSecretKey });
userId = payload.sub ? parseInt(payload.sub, 10) : undefined;
}
} catch (error) {
console.error('Failed to verify Clerk token:', error);
}
}
createContext: async (): Promise<TRPCContext> => {
const { db } = await import('../../src/db/config/migrations');
return {
userId,
db: getDb(),
userId: undefined,
db,
};
},
onError: ({ error, path }: { error: TRPCError; path: string | undefined }) => {

View File

@@ -190,7 +190,7 @@ export const projectRouter = {
.where(eq(characters.id, input.id));
const character = rows[0];
if (!character) {
throw new Error(`Character ${input.id} not found`);
throw new TRPCError({ code: 'NOT_FOUND', message: `Character ${input.id} not found` });
}
await verifyProjectOwnership(ctx.db!, character.projectId, ctx.userId!);
return character;
@@ -265,7 +265,7 @@ export const projectRouter = {
.where(eq(characters.id, input.id));
const existing = existingRows[0];
if (!existing) {
throw new Error(`Character ${input.id} not found`);
throw new TRPCError({ code: 'NOT_FOUND', message: `Character ${input.id} not found` });
}
await verifyProjectOwnership(ctx.db!, existing.projectId, ctx.userId!);
@@ -304,7 +304,7 @@ export const projectRouter = {
.where(eq(characters.id, input.id));
const existing = existingRows[0];
if (!existing) {
throw new Error(`Character ${input.id} not found`);
throw new TRPCError({ code: 'NOT_FOUND', message: `Character ${input.id} not found` });
}
await verifyProjectOwnership(ctx.db!, existing.projectId, ctx.userId!);
@@ -369,8 +369,9 @@ export const projectRouter = {
.from(characters)
.where(eq(characters.id, input.characterId));
if (!rows[0]) {
throw new Error(`Character ${input.characterId} not found`);
throw new TRPCError({ code: 'NOT_FOUND', message: `Character ${input.characterId} not found` });
}
await verifyProjectOwnership(ctx.db!, rows[0].projectId, ctx.userId!);
return await getCharacterStatsImpl(ctx.db!, input.characterId);
}),
@@ -413,7 +414,7 @@ export const projectRouter = {
.from(characters)
.where(eq(characters.id, input.characterId));
if (!rows[0]) {
throw new Error(`Character ${input.characterId} not found`);
throw new TRPCError({ code: 'NOT_FOUND', message: `Character ${input.characterId} not found` });
}
await verifyProjectOwnership(ctx.db!, rows[0].projectId, ctx.userId!);
return await ctx.db!.select()
@@ -437,7 +438,7 @@ export const projectRouter = {
}))
.mutation(async ({ input, ctx }) => {
if (input.characterIdA === input.characterIdB) {
throw new Error('Cannot create a relationship with the same character');
throw new TRPCError({ code: 'BAD_REQUEST', message: 'Cannot create a relationship with the same character' });
}
const charARows = await ctx.db!.select()
@@ -447,7 +448,7 @@ export const projectRouter = {
.from(characters)
.where(eq(characters.id, input.characterIdB));
if (!charARows[0] || !charBRows[0]) {
throw new Error('Both characters must exist');
throw new TRPCError({ code: 'NOT_FOUND', message: 'Both characters must exist' });
}
await verifyProjectOwnership(ctx.db!, charARows[0].projectId, ctx.userId!);
@@ -495,14 +496,14 @@ export const projectRouter = {
.from(characterRelationships)
.where(eq(characterRelationships.id, input.id));
if (!relRows[0]) {
throw new Error(`Relationship ${input.id} not found`);
throw new TRPCError({ code: 'NOT_FOUND', message: `Relationship ${input.id} not found` });
}
const charARows = await ctx.db!.select()
.from(characters)
.where(eq(characters.id, relRows[0].characterIdA));
if (!charARows[0]) {
throw new Error('Character not found');
throw new TRPCError({ code: 'NOT_FOUND', message: 'Character not found' });
}
await verifyProjectOwnership(ctx.db!, charARows[0].projectId, ctx.userId!);
@@ -526,14 +527,14 @@ export const projectRouter = {
.from(characterRelationships)
.where(eq(characterRelationships.id, input.id));
if (!relRows[0]) {
throw new Error(`Relationship ${input.id} not found`);
throw new TRPCError({ code: 'NOT_FOUND', message: `Relationship ${input.id} not found` });
}
const charARows = await ctx.db!.select()
.from(characters)
.where(eq(characters.id, relRows[0].characterIdA));
if (!charARows[0]) {
throw new Error('Character not found');
throw new TRPCError({ code: 'NOT_FOUND', message: 'Character not found' });
}
await verifyProjectOwnership(ctx.db!, charARows[0].projectId, ctx.userId!);
@@ -562,7 +563,7 @@ export const projectRouter = {
.where(eq(scenes.id, input.id));
const scene = rows[0];
if (!scene) {
throw new Error(`Scene ${input.id} not found`);
throw new TRPCError({ code: 'NOT_FOUND', message: `Scene ${input.id} not found` });
}
await verifyProjectOwnership(ctx.db!, scene.projectId, ctx.userId!);
return scene;
@@ -601,7 +602,7 @@ export const projectRouter = {
.from(scenes)
.where(eq(scenes.id, input.id));
if (!rows[0]) {
throw new Error(`Scene ${input.id} not found`);
throw new TRPCError({ code: 'NOT_FOUND', message: `Scene ${input.id} not found` });
}
const updateData: Record<string, any> = { updatedAt: new Date() };
@@ -623,9 +624,12 @@ export const projectRouter = {
.from(scenes)
.where(eq(scenes.id, input.id));
if (!rows[0]) {
throw new Error(`Scene ${input.id} not found`);
throw new TRPCError({ code: 'NOT_FOUND', message: `Scene ${input.id} not found` });
}
// Check project ownership
await verifyProjectOwnership(ctx.db!, rows[0].projectId, ctx.userId!);
await ctx.db!.delete(scenes)
.where(eq(scenes.id, input.id));

View File

@@ -10,8 +10,8 @@ describe('revisionsRouter', () => {
beforeEach(async () => {
await resetTestDb();
resetInMemoryState();
const db = await getTestDb();
await resetInMemoryState(db);
ctx = { userId: 1, db };
caller = appRouter.createCaller(ctx);
});

View File

@@ -1,72 +1,26 @@
import { protectedProcedure } from './router';
import { protectedProcedure, TRPCError } from './router';
import { z } from 'zod';
// In-memory storage
const revisions: Map<number, {
id: number;
scriptId: number;
versionNumber: number;
branchName: string;
parentRevisionId: number | null;
title: string;
summary: string | null;
content: string;
authorId: number;
status: 'draft' | 'pending_review' | 'accepted' | 'rejected';
reviewedById: number | null;
reviewedAt: Date | null;
createdAt: Date;
updatedAt: Date;
}> = new Map();
const revisionChanges: Map<number, {
id: number;
revisionId: number;
changeType: 'addition' | 'deletion' | 'modification';
elementType: string | null;
oldContent: string | null;
newContent: string | null;
sceneNumber: number | null;
lineNumber: number | null;
pageNumber: number | null;
createdAt: Date;
}> = new Map();
let revisionIdCounter = 0;
let changeIdCounter = 0;
function getNextRevisionId(): number {
return ++revisionIdCounter;
}
function getNextChangeId(): number {
return ++changeIdCounter;
}
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
): Map<number, {
id: number;
revisionId: number;
changeType: 'addition' | 'deletion' | 'modification';
elementType: string | null;
oldContent: string | null;
newContent: string | null;
sceneNumber: number | null;
lineNumber: number | null;
pageNumber: number | null;
createdAt: Date;
}> {
) {
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);
let sceneCounter = 0;
const changesToInsert = [];
for (let i = 0; i < maxLen; i++) {
const oldLine = oldLines[i];
const newLine = newLines[i];
@@ -87,8 +41,7 @@ function computeDiffForContent(
sceneCounter++;
}
const change = {
id: getNextChangeId(),
changesToInsert.push({
revisionId,
changeType,
elementType: null,
@@ -97,34 +50,41 @@ function computeDiffForContent(
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;
if (changesToInsert.length > 0) {
return db.insert(revisionChanges).values(changesToInsert).returning();
}
return [];
}
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;
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 function resetInMemoryState() {
revisions.clear();
revisionChanges.clear();
revisionIdCounter = 0;
changeIdCounter = 0;
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 = {
@@ -133,11 +93,19 @@ export const revisionsRouter = {
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);
.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;
}),
@@ -145,10 +113,15 @@ export const revisionsRouter = {
.input(z.object({
id: z.number().int().positive(),
}))
.query(async ({ input }) => {
const revision = revisions.get(input.id);
.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 Error(`Revision ${input.id} not found`);
throw new TRPCError({ code: 'NOT_FOUND', message: `Revision ${input.id} not found` });
}
return revision;
}),
@@ -164,37 +137,47 @@ export const revisionsRouter = {
}))
.mutation(async ({ input, ctx }) => {
if (!ctx.userId) {
throw new Error('User not authenticated');
throw new TRPCError({ code: 'UNAUTHORIZED', message: 'User not authenticated' });
}
const nextVersion = getLatestVersionForScript(
const nextVersion = await getLatestVersionForScript(
ctx.db,
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(),
};
const nextId = await getNextRevisionId(ctx.db);
revisions.set(revision.id, revision);
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 parent = revisions.get(input.parentRevisionId);
const parentResult = await ctx.db
.select()
.from(revisions)
.where(eq(revisions.id, input.parentRevisionId));
const parent = parentResult[0];
if (parent) {
computeDiffForContent(parent.content, input.content, revision.id);
await computeDiffForContent(ctx.db, parent.content, input.content, revision.id);
}
}
@@ -209,40 +192,48 @@ export const revisionsRouter = {
content: z.string().optional(),
status: z.enum(['draft', 'pending_review', 'accepted', 'rejected']).optional(),
}))
.mutation(async ({ input }) => {
const revision = revisions.get(input.id);
.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 Error(`Revision ${input.id} not found`);
throw new TRPCError({ code: 'NOT_FOUND', message: `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(),
};
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();
revisions.set(updated.id, updated);
return updated;
return updated[0]!;
}),
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`);
.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` });
}
for (const [changeId, change] of revisionChanges) {
if (change.revisionId === input.id) {
revisionChanges.delete(changeId);
}
}
await ctx.db
.delete(revisionChanges)
.where(eq(revisionChanges.revisionId, input.id));
return { success: true };
}),
@@ -251,15 +242,21 @@ export const revisionsRouter = {
.input(z.object({
revisionId: z.number().int().positive(),
}))
.query(async ({ input }) => {
const revision = revisions.get(input.revisionId);
.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 Error(`Revision ${input.revisionId} not found`);
throw new TRPCError({ code: 'NOT_FOUND', message: `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));
const changes = await ctx.db
.select()
.from(revisionChanges)
.where(eq(revisionChanges.revisionId, input.revisionId))
.orderBy(asc(revisionChanges.lineNumber));
return changes;
}),
@@ -269,15 +266,24 @@ export const revisionsRouter = {
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);
.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 Error(`Base revision ${input.baseRevisionId} not found`);
throw new TRPCError({ code: 'NOT_FOUND', message: `Base revision ${input.baseRevisionId} not found` });
}
if (!targetRevision) {
throw new Error(`Target revision ${input.targetRevisionId} not found`);
throw new TRPCError({ code: 'NOT_FOUND', message: `Target revision ${input.targetRevisionId} not found` });
}
const oldLines = baseRevision.content.split('\n');
@@ -320,24 +326,30 @@ export const revisionsRouter = {
}))
.mutation(async ({ input, ctx }) => {
if (!ctx.userId) {
throw new Error('User not authenticated');
throw new TRPCError({ code: 'UNAUTHORIZED', message: 'User not authenticated' });
}
const revision = revisions.get(input.revisionId);
const result = await ctx.db
.select()
.from(revisions)
.where(eq(revisions.id, input.revisionId));
const revision = result[0];
if (!revision) {
throw new Error(`Revision ${input.revisionId} not found`);
throw new TRPCError({ code: 'NOT_FOUND', message: `Revision ${input.revisionId} not found` });
}
const updated = {
...revision,
status: 'accepted' as const,
reviewedById: ctx.userId,
reviewedAt: new Date(),
updatedAt: new Date(),
};
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();
revisions.set(updated.id, updated);
return updated;
return updated[0]!;
}),
rejectRevision: protectedProcedure
@@ -347,27 +359,35 @@ export const revisionsRouter = {
}))
.mutation(async ({ input, ctx }) => {
if (!ctx.userId) {
throw new Error('User not authenticated');
throw new TRPCError({ code: 'UNAUTHORIZED', message: 'User not authenticated' });
}
const revision = revisions.get(input.revisionId);
const result = await ctx.db
.select()
.from(revisions)
.where(eq(revisions.id, input.revisionId));
const revision = result[0];
if (!revision) {
throw new Error(`Revision ${input.revisionId} not found`);
throw new TRPCError({ code: 'NOT_FOUND', message: `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(),
};
const newSummary = input.reason
? (revision.summary || '') + '\n[Rejected: ' + input.reason + ']'
: revision.summary;
revisions.set(updated.id, updated);
return updated;
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
@@ -377,69 +397,84 @@ export const revisionsRouter = {
}))
.mutation(async ({ input, ctx }) => {
if (!ctx.userId) {
throw new Error('User not authenticated');
throw new TRPCError({ code: 'UNAUTHORIZED', message: 'User not authenticated' });
}
const targetRevision = revisions.get(input.revisionId);
const targetResult = await ctx.db
.select()
.from(revisions)
.where(eq(revisions.id, input.revisionId));
const targetRevision = targetResult[0];
if (!targetRevision) {
throw new Error(`Revision ${input.revisionId} not found`);
throw new TRPCError({ code: 'NOT_FOUND', message: `Revision ${input.revisionId} not found` });
}
if (targetRevision.scriptId !== input.scriptId) {
throw new Error('Revision does not belong to the specified script');
throw new TRPCError({ code: 'BAD_REQUEST', message: 'Revision does not belong to the specified script' });
}
const nextVersion = getLatestVersionForScript(
const nextVersion = await getLatestVersionForScript(
ctx.db,
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(),
};
const nextId = await getNextRevisionId(ctx.db);
revisions.set(rollbackRevision.id, rollbackRevision);
return rollbackRevision;
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 }) => {
const scriptRevisions = Array.from(revisions.values())
.filter(r => r.scriptId === input.scriptId)
.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime());
.query(async ({ input, ctx }) => {
const scriptRevisions = await ctx.db
.select()
.from(revisions)
.where(eq(revisions.scriptId, input.scriptId))
.orderBy(asc(revisions.createdAt));
const timeline = scriptRevisions.map(rev => {
const changes = Array.from(revisionChanges.values())
.filter(c => c.revisionId === rev.id);
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;
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 {
revision: rev,
changeCount: changes.length,
additions,
deletions,
modifications,
};
})
);
return timeline;
}),
@@ -448,9 +483,11 @@ export const revisionsRouter = {
.input(z.object({
scriptId: z.number().int().positive(),
}))
.query(async ({ input }) => {
const scriptRevisions = Array.from(revisions.values())
.filter(r => r.scriptId === input.scriptId);
.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) {
@@ -483,30 +520,45 @@ export const revisionsRouter = {
}))
.mutation(async ({ input, ctx }) => {
if (!ctx.userId) {
throw new Error('User not authenticated');
throw new TRPCError({ code: 'UNAUTHORIZED', message: 'User not authenticated' });
}
const existing = Array.from(revisions.values())
.some(r => r.scriptId === input.scriptId && r.branchName === input.branchName);
const existingResult = await ctx.db
.select({ id: revisions.id })
.from(revisions)
.where(and(
eq(revisions.scriptId, input.scriptId),
eq(revisions.branchName, input.branchName)
));
if (existing) {
throw new Error(`Branch '${input.branchName}' already exists for this script`);
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 source = revisions.get(input.fromRevisionId);
const sourceResult = await ctx.db
.select()
.from(revisions)
.where(eq(revisions.id, input.fromRevisionId));
const source = sourceResult[0];
if (!source) {
throw new Error(`Source revision ${input.fromRevisionId} not found`);
throw new TRPCError({ code: 'NOT_FOUND', message: `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);
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;
@@ -514,25 +566,29 @@ export const revisionsRouter = {
}
}
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(),
};
const nextId = await getNextRevisionId(ctx.db);
revisions.set(branchRevision.id, branchRevision);
return branchRevision;
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
@@ -543,45 +599,56 @@ export const revisionsRouter = {
}))
.mutation(async ({ input, ctx }) => {
if (!ctx.userId) {
throw new Error('User not authenticated');
throw new TRPCError({ code: 'UNAUTHORIZED', message: 'User not authenticated' });
}
if (input.sourceBranch === input.targetBranch) {
throw new Error('Cannot merge a branch into itself');
throw new TRPCError({ code: 'BAD_REQUEST', message: '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);
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 Error(`Source branch '${input.sourceBranch}' has no revisions`);
throw new TRPCError({ code: 'NOT_FOUND', message: `Source branch '${input.sourceBranch}' has no revisions` });
}
const sourceContent = sourceRevisions[0]!.content;
const nextVersion = getLatestVersionForScript(
const nextVersion = await getLatestVersionForScript(
ctx.db,
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(),
};
const nextId = await getNextRevisionId(ctx.db);
revisions.set(mergeRevision.id, mergeRevision);
return mergeRevision;
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]!;
}),
};

View File

@@ -0,0 +1,177 @@
import { protectedProcedure } 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,
} from '../../src/db/schema';
function slugify(title: string): string {
return title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');
}
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 Error(`Script ${scriptId} not found`);
}
const projectRows = await db.select({ ownerId: projects.ownerId })
.from(projects)
.where(eq(projects.id, script.projectId));
const project = projectRows[0];
if (!project || project.ownerId !== userId) {
throw new Error(`You do not have access to script ${scriptId}`);
}
return script;
}
export const scriptsRouter = {
listScripts: protectedProcedure
.input(z.object({ projectId: z.number().int().positive() }))
.query(async ({ input, ctx }) => {
await ctx.db!.select()
.from(projects)
.where(eq(projects.id, input.projectId));
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 }) => {
const rows = await ctx.db!.select()
.from(scripts)
.where(eq(scripts.id, input.id));
const script = rows[0];
if (!script) {
throw new Error(`Script ${input.id} not found`);
}
await verifyScriptOwnership(ctx.db!, input.id, ctx.userId!);
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 }) => {
const projectRows = await ctx.db!.select()
.from(projects)
.where(eq(projects.id, input.projectId));
if (!projectRows[0]) {
throw new Error(`Project ${input.projectId} not found`);
}
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 ctx.db!.select()
.from(projects)
.where(eq(projects.id, input.projectId));
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);
}),
};

View File

@@ -85,29 +85,35 @@ const schemaSQL = `
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS revisions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
script_id INTEGER NOT NULL REFERENCES scripts(id),
title TEXT NOT NULL,
description TEXT,
version TEXT NOT NULL,
content TEXT,
created_by INTEGER NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS revisions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
script_id INTEGER NOT NULL REFERENCES scripts(id),
version_number INTEGER NOT NULL,
branch_name TEXT NOT NULL DEFAULT 'main',
parent_revision_id INTEGER,
title TEXT NOT NULL,
summary TEXT,
content TEXT NOT NULL,
author_id INTEGER NOT NULL REFERENCES users(id),
status TEXT NOT NULL DEFAULT 'draft' CHECK(status IN ('draft', 'pending_review', 'accepted', 'rejected')),
reviewed_by_id INTEGER REFERENCES users(id),
reviewed_at TIMESTAMP,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS revision_changes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
revision_id INTEGER NOT NULL REFERENCES revisions(id),
change_type TEXT NOT NULL,
description TEXT,
scene_number INTEGER,
line_number INTEGER,
old_content TEXT,
new_content TEXT,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS revision_changes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
revision_id INTEGER NOT NULL REFERENCES revisions(id),
change_type TEXT NOT NULL CHECK(change_type IN ('addition', 'deletion', 'modification')),
element_type TEXT,
old_content TEXT,
new_content TEXT,
scene_number INTEGER,
line_number INTEGER,
page_number INTEGER,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
`;
export async function getTestDb(): Promise<ReturnType<typeof drizzle>> {
@@ -121,6 +127,12 @@ export async function getTestDb(): Promise<ReturnType<typeof drizzle>> {
// Insert a test user
sqlite.exec("INSERT INTO users (id, email, name) VALUES (1, 'test@test.com', 'Test User');");
// Insert a test project
sqlite.exec("INSERT INTO projects (id, name, description, owner_id) VALUES (1, 'Test Project', 'A test project', 1);");
// Insert a test script
sqlite.exec("INSERT INTO scripts (id, project_id, title, version) VALUES (1, 1, 'Test Script', '1.0');");
testDb = drizzle(sqlite);
return testDb;