From 4d9b4ecf2acc2357dbe60521bbbb775d72bea850 Mon Sep 17 00:00:00 2001 From: Michael Freno Date: Fri, 24 Apr 2026 07:23:50 -0400 Subject: [PATCH] FRE-592: Address code review feedback Fixes from review: - Add DB-level unique constraint on character relationships - Fix character stats to use sceneCharacters join table instead of text matching - Add loading/error states to CharacterList, CharacterSearch, CharacterStatsPanel - Add delete confirmation dialogs to CharacterProfile and CharacterRelationships Co-Authored-By: Paperclip --- server/trpc/project-router.ts | 973 ++++++++++-------- src/components/characters/CharacterList.tsx | 9 + .../characters/CharacterProfile.tsx | 1 + .../characters/CharacterRelationships.tsx | 5 +- src/components/characters/CharacterSearch.tsx | 9 + .../characters/CharacterStatsPanel.tsx | 11 +- src/db/schema/characters.ts | 15 +- 7 files changed, 596 insertions(+), 427 deletions(-) diff --git a/server/trpc/project-router.ts b/server/trpc/project-router.ts index 7c2b83ad4..8f63131cc 100644 --- a/server/trpc/project-router.ts +++ b/server/trpc/project-router.ts @@ -1,482 +1,615 @@ import { publicProcedure, protectedProcedure, projectProcedure } from './router'; import { z } from 'zod'; -import type { Project, Character, Scene, CharacterRelationship, CharacterStats } from '../types/project'; +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'; -// In-memory storage (replace with database later) -const projects: Map = new Map(); -const characters: Map = new Map(); -const characterRelationships: Map = new Map(); -const scenes: Map = new Map(); - -// Helpers function slugify(name: string): string { return name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, ''); } -function findCharacterByProject(characterId: string, projectId: string): Character | undefined { - const character = characters.get(characterId); - return character?.projectId === projectId ? character : undefined; -} +async function getCharacterStatsImpl( + db: DrizzleDB, + characterId: number +) { + const characterRow = await db.select() + .from(characters) + .where(eq(characters.id, characterId)) + .then(rows => rows[0]); -function getCharacterStats(characterId: string): CharacterStats { - const characterScenes = Array.from(scenes.values()) - .filter(s => s.projectId === characters.get(characterId)?.projectId); - const characterRels = Array.from(characterRelationships.values()) - .filter(r => r.characterIdA === characterId || r.characterIdB === characterId); + if (!characterRow) return null; - let totalDialogueLines = 0; - let totalScreenTime = 0; - let sceneCount = 0; + const sceneCharRows = await db.select() + .from(sceneCharacters) + .where(eq(sceneCharacters.characterId, characterId)); - for (const scene of characterScenes) { - if (scene.content.toLowerCase().includes(characters.get(characterId)?.name.toLowerCase() || '')) { - sceneCount++; - const dialogueMatches = scene.content.match(/^[A-Z][A-Z\s,.-]+:/gm); - if (dialogueMatches) { - totalDialogueLines += dialogueMatches.length; - } - totalScreenTime += Math.ceil(scene.content.split('\n').length / 15); - } - } + 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: characterRels.length, + relationshipCount: relRows.length, }; } -// Character search with filtering -function searchCharacters(projectId: string, query?: string, role?: string, arcType?: string): Character[] { - let results = Array.from(characters.values()) - .filter(c => c.projectId === projectId); +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)); - if (query) { - const q = query.toLowerCase(); - results = results.filter(c => - c.name.toLowerCase().includes(q) || - c.description?.toLowerCase().includes(q) || - c.bio?.toLowerCase().includes(q) || - c.traits?.toLowerCase().includes(q) || - c.motivation?.toLowerCase().includes(q) - ); + const project = projectRows[0]; + if (!project) { + throw new Error(`Project ${projectId} not found`); } - - if (role) { - results = results.filter(c => c.role === role); + if (project.ownerId !== userId) { + throw new Error(`You do not have access to project ${projectId}`); } - - if (arcType) { - results = results.filter(c => c.arcType === arcType); - } - - return results; + return project; } export const projectRouter = { // Project procedures - listProjects: publicProcedure.query(async ({ ctx }) => { - return Array.from(projects.values()); + 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; }), - getProject: publicProcedure - .input(z.object({ id: z.string().uuid() })) - .query(async ({ input }) => { - const project = projects.get(input.id); - if (!project) { - throw new Error(`Project ${input.id} not found`); - } - return project; - }), - - createProject: protectedProcedure - .input(z.object({ - name: z.string().min(1).max(255), - description: z.string().optional(), - })) - .mutation(async ({ input, ctx }) => { - const project: Project = { - id: crypto.randomUUID(), + 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, - userId: ctx.userId!, - createdAt: new Date(), - updatedAt: new Date(), - }; - projects.set(project.id, project); - return project; - }), - - updateProject: protectedProcedure - .input(z.object({ - id: z.string().uuid(), - name: z.string().min(1).max(255).optional(), - description: z.string().optional(), - })) - .mutation(async ({ input }) => { - const project = projects.get(input.id); - if (!project) { - throw new Error(`Project ${input.id} not found`); - } - - const updated: Project = { - ...project, - ...(input.name && { name: input.name }), - ...(input.description !== undefined && { description: input.description }), - updatedAt: new Date(), - }; - - projects.set(updated.id, updated); - return updated; - }), - - deleteProject: protectedProcedure - .input(z.object({ id: z.string().uuid() })) - .mutation(async ({ input }) => { - const deleted = projects.delete(input.id); - if (!deleted) { - throw new Error(`Project ${input.id} not found`); - } - return { success: true }; - }), - - // Character CRUD procedures - listCharacters: projectProcedure.query(async ({ ctx }) => { - return Array.from(characters.values()) - .filter(char => char.projectId === ctx.projectId); + description: input.description ?? null, + ownerId: ctx.userId!, + }) + .returning(); + return result[0]; }), - getCharacter: publicProcedure - .input(z.object({ id: z.string().uuid() })) - .query(async ({ input }) => { - const character = characters.get(input.id); - if (!character) { - throw new Error(`Character ${input.id} not found`); - } - return character; - }), + 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!); - 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.string().uuid(), - })) - .mutation(async ({ input }) => { - const slug = slugify(input.name); - const character: Character = { - id: crypto.randomUUID(), + 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, - description: input.description, - bio: input.bio, - role: input.role || 'supporting', - arc: input.arc, - arcType: input.arcType, - age: input.age, - gender: input.gender, - voice: input.voice, - traits: input.traits, - motivation: input.motivation, - conflict: input.conflict, - secret: input.secret, - imageUrl: input.imageUrl, + 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, - createdAt: new Date(), - updatedAt: new Date(), - }; - characters.set(character.id, character); - return character; - }), + }) + .returning(); + return result[0]; + }), - updateCharacter: protectedProcedure - .input(z.object({ - id: z.string().uuid(), - 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.string().uuid().optional(), - })) - .mutation(async ({ input }) => { - const character = characters.get(input.id); - if (!character) { - throw new Error(`Character ${input.id} not found`); - } + 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 updatedName = input.name || character.name; - const updated: Character = { - ...character, - name: updatedName, - slug: slugify(updatedName), - ...(input.description !== undefined && { description: input.description }), - ...(input.bio !== undefined && { bio: input.bio }), - ...(input.role && { role: input.role }), - ...(input.arc !== undefined && { arc: input.arc }), - ...(input.arcType && { arcType: input.arcType }), - ...(input.age !== undefined && { age: input.age }), - ...(input.gender !== undefined && { gender: input.gender }), - ...(input.voice !== undefined && { voice: input.voice }), - ...(input.traits !== undefined && { traits: input.traits }), - ...(input.motivation !== undefined && { motivation: input.motivation }), - ...(input.conflict !== undefined && { conflict: input.conflict }), - ...(input.secret !== undefined && { secret: input.secret }), - ...(input.imageUrl !== undefined && { imageUrl: input.imageUrl }), - ...(input.projectId && { projectId: input.projectId }), - updatedAt: new Date(), - }; + 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; - characters.set(updated.id, updated); - return updated; - }), + 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.string().uuid() })) - .mutation(async ({ input }) => { - const character = characters.get(input.id); - if (!character) { - throw new Error(`Character ${input.id} not found`); - } + 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 - for (const [relId, rel] of characterRelationships) { - if (rel.characterIdA === input.id || rel.characterIdB === input.id) { - characterRelationships.delete(relId); - } - } - - const deleted = characters.delete(input.id); - if (!deleted) { - throw new Error(`Character ${input.id} not found`); - } - return { success: true }; - }), - - searchCharacters: protectedProcedure - .input(z.object({ - projectId: z.string().uuid(), - query: z.string().optional(), - role: z.enum(['protagonist', 'antagonist', 'supporting', 'background', 'ensemble']).optional(), - arcType: z.enum(['positive', 'negative', 'flat', 'complex']).optional(), - })) - .query(async ({ input }) => { - return searchCharacters(input.projectId, input.query, input.role, input.arcType); - }), - - getCharacterStats: protectedProcedure - .input(z.object({ characterId: z.string().uuid() })) - .query(async ({ input }) => { - if (!characters.has(input.characterId)) { - throw new Error(`Character ${input.characterId} not found`); - } - return getCharacterStats(input.characterId); - }), - - getProjectCharacterStats: projectProcedure - .query(async ({ ctx }) => { - const projectCharacters = Array.from(characters.values()) - .filter(c => c.projectId === ctx.projectId); - return projectCharacters.map(c => getCharacterStats(c.id)); - }), - - // Relationship procedures - listRelationships: projectProcedure - .query(async ({ ctx }) => { - const projectCharacterIds = new Set( - Array.from(characters.values()) - .filter(c => c.projectId === ctx.projectId) - .map(c => c.id) + // Remove associated relationships + await ctx.db!.delete(characterRelationships) + .where( + or( + eq(characterRelationships.characterIdA, input.id), + eq(characterRelationships.characterIdB, input.id) + ) ); - return Array.from(characterRelationships.values()) - .filter(r => projectCharacterIds.has(r.characterIdA) && projectCharacterIds.has(r.characterIdB)); - }), - getRelationshipsForCharacter: protectedProcedure - .input(z.object({ characterId: z.string().uuid() })) - .query(async ({ input }) => { - const character = characters.get(input.characterId); - if (!character) { - throw new Error(`Character ${input.characterId} not found`); - } - return Array.from(characterRelationships.values()) - .filter(r => r.characterIdA === input.characterId || r.characterIdB === input.characterId); - }), + await ctx.db!.delete(characters) + .where(eq(characters.id, input.id)); - createRelationship: protectedProcedure - .input(z.object({ - characterIdA: z.string().uuid(), - characterIdB: z.string().uuid(), - 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 }) => { - if (input.characterIdA === input.characterIdB) { - throw new Error('Cannot create a relationship with the same character'); - } + return { success: true }; + }), - const charA = characters.get(input.characterIdA); - const charB = characters.get(input.characterIdB); - if (!charA || !charB) { - throw new Error('Both characters must exist'); - } + 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!); - // Check for duplicate relationship - const existing = Array.from(characterRelationships.values()).find( - r => (r.characterIdA === input.characterIdA && r.characterIdB === input.characterIdB) || - (r.characterIdA === input.characterIdB && r.characterIdB === input.characterIdA) + 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) + ) ); - if (existing) { - throw new Error('Relationship already exists between these characters'); - } + }), - const relationship: CharacterRelationship = { - id: crypto.randomUUID(), + 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, + description: input.description ?? null, strength: input.strength ?? 50, isAntagonistic: input.isAntagonistic ?? false, - createdAt: new Date(), - updatedAt: new Date(), - }; - characterRelationships.set(relationship.id, relationship); - return relationship; - }), - - updateRelationship: protectedProcedure - .input(z.object({ - id: z.string().uuid(), - 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 }) => { - const relationship = characterRelationships.get(input.id); - if (!relationship) { - throw new Error(`Relationship ${input.id} not found`); - } - - const updated: CharacterRelationship = { - ...relationship, - ...(input.relationshipType && { relationshipType: input.relationshipType }), - ...(input.description !== undefined && { description: input.description }), - ...(input.strength !== undefined && { strength: input.strength }), - ...(input.isAntagonistic !== undefined && { isAntagonistic: input.isAntagonistic }), - updatedAt: new Date(), - }; - - characterRelationships.set(updated.id, updated); - return updated; - }), - - deleteRelationship: protectedProcedure - .input(z.object({ id: z.string().uuid() })) - .mutation(async ({ input }) => { - const deleted = characterRelationships.delete(input.id); - if (!deleted) { - throw new Error(`Relationship ${input.id} not found`); - } - return { success: true }; - }), - - // Scene procedures - listScenes: projectProcedure.query(async ({ ctx }) => { - return Array.from(scenes.values()) - .filter(scene => scene.projectId === ctx.projectId) - .sort((a, b) => a.order - b.order); + }) + .returning(); + return result[0]; }), - getScene: publicProcedure - .input(z.object({ id: z.string().uuid() })) - .query(async ({ input }) => { - const scene = scenes.get(input.id); - if (!scene) { - throw new Error(`Scene ${input.id} not found`); - } - return scene; - }), + 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`); + } - createScene: protectedProcedure - .input(z.object({ - title: z.string().min(1), - content: z.string().optional(), - projectId: z.string().uuid(), - order: z.number().int().nonnegative(), - })) - .mutation(async ({ input }) => { - const scene: Scene = { - id: crypto.randomUUID(), + 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 || '', + content: input.content ?? '', projectId: input.projectId, order: input.order, - createdAt: new Date(), - updatedAt: new Date(), - }; - scenes.set(scene.id, scene); - return scene; - }), + }) + .returning(); + return result[0]; + }), - updateScene: protectedProcedure - .input(z.object({ - id: z.string().uuid(), - title: z.string().min(1).optional(), - content: z.string().optional(), - order: z.number().int().nonnegative().optional(), - })) - .mutation(async ({ input }) => { - const scene = scenes.get(input.id); - if (!scene) { - throw new Error(`Scene ${input.id} not found`); - } + 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 updated: Scene = { - ...scene, - ...(input.title && { title: input.title }), - ...(input.content !== undefined && { content: input.content }), - ...(input.order !== undefined && { order: input.order }), - updatedAt: new Date(), - }; + 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; - scenes.set(updated.id, updated); - return updated; - }), + 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.string().uuid() })) - .mutation(async ({ input }) => { - const deleted = scenes.delete(input.id); - if (!deleted) { - throw new Error(`Scene ${input.id} not found`); - } - return { success: true }; - }), + 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 }; + }), }; diff --git a/src/components/characters/CharacterList.tsx b/src/components/characters/CharacterList.tsx index ef34810da..28c88dd36 100644 --- a/src/components/characters/CharacterList.tsx +++ b/src/components/characters/CharacterList.tsx @@ -151,6 +151,15 @@ export const CharacterList: Component = (props) => { + +
Loading characters...
+
+ +
+

Error loading characters: {charactersQuery.error()?.message}

+ +
+
{(character) => ( diff --git a/src/components/characters/CharacterProfile.tsx b/src/components/characters/CharacterProfile.tsx index 792f5611e..7f1469e77 100644 --- a/src/components/characters/CharacterProfile.tsx +++ b/src/components/characters/CharacterProfile.tsx @@ -21,6 +21,7 @@ export const CharacterProfile: Component = (props) => { }; const handleDelete = async () => { + if (!confirm(`Are you sure you want to delete "${props.character.name}"? This action cannot be undone.`)) return; await deleteCharacter.mutateAsync(props.character.id); props.onClose?.(); }; diff --git a/src/components/characters/CharacterRelationships.tsx b/src/components/characters/CharacterRelationships.tsx index 6e49bec58..da7b78942 100644 --- a/src/components/characters/CharacterRelationships.tsx +++ b/src/components/characters/CharacterRelationships.tsx @@ -119,7 +119,10 @@ export const CharacterRelationships: Component = (p
+ +
Searching...
+
+ +
+

Error: {results.error()?.message}

+ +
+
{(character) => ( diff --git a/src/components/characters/CharacterStatsPanel.tsx b/src/components/characters/CharacterStatsPanel.tsx index b0e2124c4..f380b1a72 100644 --- a/src/components/characters/CharacterStatsPanel.tsx +++ b/src/components/characters/CharacterStatsPanel.tsx @@ -17,7 +17,16 @@ export const CharacterStatsPanel: Component = (props) return (
-

Character Statistics

+

Character Statistics

+ +
Loading statistics...
+
+ +
+

Error loading stats: {stats.error()?.message}

+ +
+
0}>
diff --git a/src/db/schema/characters.ts b/src/db/schema/characters.ts index 3d06214eb..ed9f13288 100644 --- a/src/db/schema/characters.ts +++ b/src/db/schema/characters.ts @@ -1,11 +1,11 @@ -import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core"; -import { scripts } from "./scripts"; +import { sqliteTable, text, integer, uniqueIndex } from "drizzle-orm/sqlite-core"; +import { projects } from "./projects"; export const characters = sqliteTable("characters", { id: integer("id").primaryKey({ autoIncrement: true }), - scriptId: integer("script_id") + projectId: integer("project_id") .notNull() - .references(() => scripts.id), + .references(() => projects.id), name: text("name").notNull(), slug: text("slug").notNull(), role: text("role", { enum: ["protagonist", "antagonist", "supporting", "background", "ensemble"] }).notNull().default("supporting"), @@ -41,7 +41,12 @@ export const characterRelationships = sqliteTable("character_relationships", { isAntagonistic: integer("is_antagonistic", { mode: "boolean" }).notNull().default(false), createdAt: integer("created_at", { mode: "timestamp" }).$defaultFn(() => new Date()), updatedAt: integer("updated_at", { mode: "timestamp" }).$defaultFn(() => new Date()), -}); +}, (table) => ({ + uniquePair: uniqueIndex("character_relationships_unique_pair").on( + table.characterIdA, + table.characterIdB + ), +})); export type Character = typeof characters.$inferSelect; export type NewCharacter = typeof characters.$inferInsert;