import { publicProcedure, protectedProcedure, projectProcedure } from './router'; import { z } from 'zod'; import { eq, and, or, like, sql, inArray } from 'drizzle-orm'; import type { DrizzleDB } from '../../src/db/config/migrations'; import { projects, characters, characterRelationships, scenes, sceneCharacters, } from '../../src/db/schema'; function slugify(name: string): string { return name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, ''); } async function getCharacterStatsImpl( db: DrizzleDB, characterId: number ) { const characterRow = await db.select() .from(characters) .where(eq(characters.id, characterId)) .then(rows => rows[0]); if (!characterRow) return null; const sceneCharRows = await db.select() .from(sceneCharacters) .where(eq(sceneCharacters.characterId, characterId)); const relRows = await db.select() .from(characterRelationships) .where( or( eq(characterRelationships.characterIdA, characterId), eq(characterRelationships.characterIdB, characterId) ) ); const sceneCount = sceneCharRows.length; const totalDialogueLines = sceneCharRows.reduce( (sum, sc) => sum + (sc.dialogueLines || 0), 0 ); const totalScreenTime = sceneCharRows.reduce( (sum, sc) => sum + (sc.screenTime || 0), 0 ); return { characterId, totalScreenTime, totalDialogueLines, sceneCount, relationshipCount: relRows.length, }; } async function verifyProjectOwnership( 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 Error(`Project ${projectId} not found`); } if (project.ownerId !== userId) { throw new Error(`You do not have access to project ${projectId}`); } return project; } export const projectRouter = { // Project procedures listProjects: protectedProcedure.query(async ({ ctx }) => { return await ctx.db!.select() .from(projects) .where(eq(projects.ownerId, ctx.userId!)) .orderBy(projects.updatedAt); }), getProject: protectedProcedure .input(z.object({ id: z.number().int().positive() })) .query(async ({ input, ctx }) => { const rows = await ctx.db!.select() .from(projects) .where(eq(projects.id, input.id)); const project = rows[0]; if (!project) { throw new Error(`Project ${input.id} not found`); } if (project.ownerId !== ctx.userId && !project.isPublic) { throw new Error(`You do not have access to project ${input.id}`); } return project; }), createProject: protectedProcedure .input(z.object({ name: z.string().min(1).max(255), description: z.string().optional(), })) .mutation(async ({ input, ctx }) => { const result = await ctx.db!.insert(projects) .values({ name: input.name, description: input.description ?? null, ownerId: ctx.userId!, }) .returning(); return result[0]; }), updateProject: protectedProcedure .input(z.object({ id: z.number().int().positive(), name: z.string().min(1).max(255).optional(), description: z.string().optional(), })) .mutation(async ({ input, ctx }) => { await verifyProjectOwnership(ctx.db!, input.id, ctx.userId!); const updateData: Record = { updatedAt: new Date() }; if (input.name !== undefined) updateData.name = input.name; if (input.description !== undefined) updateData.description = input.description ?? null; const result = await ctx.db!.update(projects) .set(updateData) .where(eq(projects.id, input.id)) .returning(); return result[0]; }), deleteProject: protectedProcedure .input(z.object({ id: z.number().int().positive() })) .mutation(async ({ input, ctx }) => { await verifyProjectOwnership(ctx.db!, input.id, ctx.userId!); // Cascade delete: remove scenes first await ctx.db!.delete(scenes) .where(eq(scenes.projectId, input.id)); // Get character IDs for this project const projectCharacters = await ctx.db!.select({ id: characters.id }) .from(characters) .where(eq(characters.projectId, input.id)); // Delete relationships for each character for (const char of projectCharacters) { await ctx.db!.delete(characterRelationships) .where( or( eq(characterRelationships.characterIdA, char.id), eq(characterRelationships.characterIdB, char.id) ) ); } // Delete characters await ctx.db!.delete(characters) .where(eq(characters.projectId, input.id)); // Delete project const result = await ctx.db!.delete(projects) .where(eq(projects.id, input.id)); return { success: true }; }), // Character CRUD procedures listCharacters: projectProcedure.query(async ({ ctx }) => { return await ctx.db!.select() .from(characters) .where(eq(characters.projectId, ctx.projectId!)) .orderBy(characters.name); }), getCharacter: protectedProcedure .input(z.object({ id: z.number().int().positive() })) .query(async ({ input, ctx }) => { const rows = await ctx.db!.select() .from(characters) .where(eq(characters.id, input.id)); const character = rows[0]; if (!character) { throw new Error(`Character ${input.id} not found`); } return character; }), createCharacter: protectedProcedure .input(z.object({ name: z.string().min(1).max(100), description: z.string().optional(), bio: z.string().optional(), role: z.enum(['protagonist', 'antagonist', 'supporting', 'background', 'ensemble']).optional(), arc: z.string().optional(), arcType: z.enum(['positive', 'negative', 'flat', 'complex']).optional(), age: z.number().int().optional(), gender: z.string().optional(), voice: z.string().optional(), traits: z.string().optional(), motivation: z.string().optional(), conflict: z.string().optional(), secret: z.string().optional(), imageUrl: z.string().url().optional(), projectId: z.number().int().positive(), })) .mutation(async ({ input, ctx }) => { await verifyProjectOwnership(ctx.db!, input.projectId, ctx.userId!); const result = await ctx.db!.insert(characters) .values({ name: input.name, slug: slugify(input.name), description: input.description ?? null, bio: input.bio ?? null, role: input.role ?? 'supporting', arc: input.arc ?? null, arcType: input.arcType ?? null, age: input.age ?? null, gender: input.gender ?? null, voice: input.voice ?? null, traits: input.traits ?? null, motivation: input.motivation ?? null, conflict: input.conflict ?? null, secret: input.secret ?? null, imageUrl: input.imageUrl ?? null, projectId: input.projectId, }) .returning(); return result[0]; }), updateCharacter: protectedProcedure .input(z.object({ id: z.number().int().positive(), name: z.string().min(1).max(100).optional(), description: z.string().optional(), bio: z.string().optional(), role: z.enum(['protagonist', 'antagonist', 'supporting', 'background', 'ensemble']).optional(), arc: z.string().optional(), arcType: z.enum(['positive', 'negative', 'flat', 'complex']).optional(), age: z.number().int().optional(), gender: z.string().optional(), voice: z.string().optional(), traits: z.string().optional(), motivation: z.string().optional(), conflict: z.string().optional(), secret: z.string().optional(), imageUrl: z.string().url().optional(), projectId: z.number().int().positive().optional(), })) .mutation(async ({ input, ctx }) => { const existingRows = await ctx.db!.select() .from(characters) .where(eq(characters.id, input.id)); const existing = existingRows[0]; if (!existing) { throw new Error(`Character ${input.id} not found`); } await verifyProjectOwnership(ctx.db!, existing.projectId, ctx.userId!); const updateData: Record = { updatedAt: new Date() }; if (input.name !== undefined) { updateData.name = input.name; updateData.slug = slugify(input.name); } if (input.description !== undefined) updateData.description = input.description ?? null; if (input.bio !== undefined) updateData.bio = input.bio ?? null; if (input.role !== undefined) updateData.role = input.role; if (input.arc !== undefined) updateData.arc = input.arc ?? null; if (input.arcType !== undefined) updateData.arcType = input.arcType ?? null; if (input.age !== undefined) updateData.age = input.age ?? null; if (input.gender !== undefined) updateData.gender = input.gender ?? null; if (input.voice !== undefined) updateData.voice = input.voice ?? null; if (input.traits !== undefined) updateData.traits = input.traits ?? null; if (input.motivation !== undefined) updateData.motivation = input.motivation ?? null; if (input.conflict !== undefined) updateData.conflict = input.conflict ?? null; if (input.secret !== undefined) updateData.secret = input.secret ?? null; if (input.imageUrl !== undefined) updateData.imageUrl = input.imageUrl ?? null; if (input.projectId !== undefined) updateData.projectId = input.projectId; const result = await ctx.db!.update(characters) .set(updateData) .where(eq(characters.id, input.id)) .returning(); return result[0]; }), deleteCharacter: protectedProcedure .input(z.object({ id: z.number().int().positive() })) .mutation(async ({ input, ctx }) => { const existingRows = await ctx.db!.select() .from(characters) .where(eq(characters.id, input.id)); const existing = existingRows[0]; if (!existing) { throw new Error(`Character ${input.id} not found`); } await verifyProjectOwnership(ctx.db!, existing.projectId, ctx.userId!); // Remove associated relationships await ctx.db!.delete(characterRelationships) .where( or( eq(characterRelationships.characterIdA, input.id), eq(characterRelationships.characterIdB, input.id) ) ); await ctx.db!.delete(characters) .where(eq(characters.id, input.id)); return { success: true }; }), searchCharacters: protectedProcedure .input(z.object({ projectId: z.number().int().positive(), query: z.string().optional(), role: z.enum(['protagonist', 'antagonist', 'supporting', 'background', 'ensemble']).optional(), arcType: z.enum(['positive', 'negative', 'flat', 'complex']).optional(), })) .query(async ({ input, ctx }) => { await verifyProjectOwnership(ctx.db!, input.projectId, ctx.userId!); let conditions: import('drizzle-orm').SQL[] = [eq(characters.projectId, input.projectId)]; if (input.query) { const q = `%${input.query.toLowerCase()}%`; conditions = [ eq(characters.projectId, input.projectId), or( like(sql`LOWER(${characters.name})`, q), like(sql`LOWER(COALESCE(${characters.description}, ''))`, q), like(sql`LOWER(COALESCE(${characters.bio}, ''))`, q), like(sql`LOWER(COALESCE(${characters.traits}, ''))`, q), like(sql`LOWER(COALESCE(${characters.motivation}, ''))`, q) )!, ]; } if (input.role) { conditions = [ eq(characters.projectId, input.projectId), eq(characters.role, input.role), ]; } if (input.arcType) { conditions = [ eq(characters.projectId, input.projectId), eq(characters.arcType, input.arcType), ]; } return await ctx.db!.select() .from(characters) .where(and(...conditions)) .orderBy(characters.name); }), getCharacterStats: protectedProcedure .input(z.object({ characterId: z.number().int().positive() })) .query(async ({ input, ctx }) => { const rows = await ctx.db!.select() .from(characters) .where(eq(characters.id, input.characterId)); if (!rows[0]) { throw new Error(`Character ${input.characterId} not found`); } return await getCharacterStatsImpl(ctx.db!, input.characterId); }), getProjectCharacterStats: projectProcedure .query(async ({ ctx }) => { const projectCharacters = await ctx.db!.select() .from(characters) .where(eq(characters.projectId, ctx.projectId!)); const stats = []; for (const c of projectCharacters) { const s = await getCharacterStatsImpl(ctx.db!, c.id); if (s) stats.push(s); } return stats; }), // Relationship procedures listRelationships: projectProcedure .query(async ({ ctx }) => { const projectCharacterIds = await ctx.db!.select({ id: characters.id }) .from(characters) .where(eq(characters.projectId, ctx.projectId!)); const idList = projectCharacterIds.map(c => c.id); if (idList.length === 0) return []; return await ctx.db!.select() .from(characterRelationships) .where( and( inArray(characterRelationships.characterIdA, idList), inArray(characterRelationships.characterIdB, idList) ) ); }), getRelationshipsForCharacter: protectedProcedure .input(z.object({ characterId: z.number().int().positive() })) .query(async ({ input, ctx }) => { const rows = await ctx.db!.select() .from(characters) .where(eq(characters.id, input.characterId)); if (!rows[0]) { throw new Error(`Character ${input.characterId} not found`); } return await ctx.db!.select() .from(characterRelationships) .where( or( eq(characterRelationships.characterIdA, input.characterId), eq(characterRelationships.characterIdB, input.characterId) ) ); }), createRelationship: protectedProcedure .input(z.object({ characterIdA: z.number().int().positive(), characterIdB: z.number().int().positive(), relationshipType: z.enum(['family', 'romantic', 'friendship', 'rivalry', 'mentor', 'alliance', 'conflict', 'professional', 'other']), description: z.string().optional(), strength: z.number().int().min(0).max(100).optional(), isAntagonistic: z.boolean().optional(), })) .mutation(async ({ input, ctx }) => { if (input.characterIdA === input.characterIdB) { throw new Error('Cannot create a relationship with the same character'); } const charARows = await ctx.db!.select() .from(characters) .where(eq(characters.id, input.characterIdA)); const charBRows = await ctx.db!.select() .from(characters) .where(eq(characters.id, input.characterIdB)); if (!charARows[0] || !charBRows[0]) { throw new Error('Both characters must exist'); } const existing = await ctx.db!.select() .from(characterRelationships) .where( or( and( eq(characterRelationships.characterIdA, input.characterIdA), eq(characterRelationships.characterIdB, input.characterIdB) ), and( eq(characterRelationships.characterIdA, input.characterIdB), eq(characterRelationships.characterIdB, input.characterIdA) ) ) ); if (existing.length > 0) { throw new Error('Relationship already exists between these characters'); } const result = await ctx.db!.insert(characterRelationships) .values({ characterIdA: input.characterIdA, characterIdB: input.characterIdB, relationshipType: input.relationshipType, description: input.description ?? null, strength: input.strength ?? 50, isAntagonistic: input.isAntagonistic ?? false, }) .returning(); return result[0]; }), updateRelationship: protectedProcedure .input(z.object({ id: z.number().int().positive(), relationshipType: z.enum(['family', 'romantic', 'friendship', 'rivalry', 'mentor', 'alliance', 'conflict', 'professional', 'other']).optional(), description: z.string().optional(), strength: z.number().int().min(0).max(100).optional(), isAntagonistic: z.boolean().optional(), })) .mutation(async ({ input, ctx }) => { const rows = await ctx.db!.select() .from(characterRelationships) .where(eq(characterRelationships.id, input.id)); if (!rows[0]) { throw new Error(`Relationship ${input.id} not found`); } const updateData: Record = { updatedAt: new Date() }; if (input.relationshipType !== undefined) updateData.relationshipType = input.relationshipType; if (input.description !== undefined) updateData.description = input.description ?? null; if (input.strength !== undefined) updateData.strength = input.strength; if (input.isAntagonistic !== undefined) updateData.isAntagonistic = input.isAntagonistic; const result = await ctx.db!.update(characterRelationships) .set(updateData) .where(eq(characterRelationships.id, input.id)) .returning(); return result[0]; }), deleteRelationship: protectedProcedure .input(z.object({ id: z.number().int().positive() })) .mutation(async ({ input, ctx }) => { const rows = await ctx.db!.select() .from(characterRelationships) .where(eq(characterRelationships.id, input.id)); if (!rows[0]) { throw new Error(`Relationship ${input.id} not found`); } await ctx.db!.delete(characterRelationships) .where(eq(characterRelationships.id, input.id)); return { success: true }; }), // Scene procedures listScenes: projectProcedure.query(async ({ ctx }) => { return await ctx.db!.select() .from(scenes) .where(eq(scenes.projectId, ctx.projectId!)) .orderBy(scenes.order); }), getScene: protectedProcedure .input(z.object({ id: z.number().int().positive() })) .query(async ({ input, ctx }) => { const rows = await ctx.db!.select() .from(scenes) .where(eq(scenes.id, input.id)); const scene = rows[0]; if (!scene) { throw new Error(`Scene ${input.id} not found`); } return scene; }), createScene: protectedProcedure .input(z.object({ title: z.string().min(1), content: z.string().optional(), projectId: z.number().int().positive(), order: z.number().int().nonnegative(), })) .mutation(async ({ input, ctx }) => { await verifyProjectOwnership(ctx.db!, input.projectId, ctx.userId!); const result = await ctx.db!.insert(scenes) .values({ title: input.title, content: input.content ?? '', projectId: input.projectId, order: input.order, }) .returning(); return result[0]; }), updateScene: protectedProcedure .input(z.object({ id: z.number().int().positive(), title: z.string().min(1).optional(), content: z.string().optional(), order: z.number().int().nonnegative().optional(), })) .mutation(async ({ input, ctx }) => { const rows = await ctx.db!.select() .from(scenes) .where(eq(scenes.id, input.id)); if (!rows[0]) { throw new Error(`Scene ${input.id} not found`); } const updateData: Record = { updatedAt: new Date() }; if (input.title !== undefined) updateData.title = input.title; if (input.content !== undefined) updateData.content = input.content ?? ''; if (input.order !== undefined) updateData.order = input.order; const result = await ctx.db!.update(scenes) .set(updateData) .where(eq(scenes.id, input.id)) .returning(); return result[0]; }), deleteScene: protectedProcedure .input(z.object({ id: z.number().int().positive() })) .mutation(async ({ input, ctx }) => { const rows = await ctx.db!.select() .from(scenes) .where(eq(scenes.id, input.id)); if (!rows[0]) { throw new Error(`Scene ${input.id} not found`); } await ctx.db!.delete(scenes) .where(eq(scenes.id, input.id)); return { success: true }; }), };