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 <noreply@paperclip.ing>
This commit is contained in:
2026-04-24 07:23:50 -04:00
parent ccbf3039d9
commit 4d9b4ecf2a
7 changed files with 596 additions and 427 deletions

View File

@@ -1,93 +1,101 @@
import { publicProcedure, protectedProcedure, projectProcedure } from './router'; import { publicProcedure, protectedProcedure, projectProcedure } from './router';
import { z } from 'zod'; 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<string, Project> = new Map();
const characters: Map<string, Character> = new Map();
const characterRelationships: Map<string, CharacterRelationship> = new Map();
const scenes: Map<string, Scene> = new Map();
// Helpers
function slugify(name: string): string { function slugify(name: string): string {
return name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, ''); return name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');
} }
function findCharacterByProject(characterId: string, projectId: string): Character | undefined { async function getCharacterStatsImpl(
const character = characters.get(characterId); db: DrizzleDB,
return character?.projectId === projectId ? character : undefined; characterId: number
} ) {
const characterRow = await db.select()
.from(characters)
.where(eq(characters.id, characterId))
.then(rows => rows[0]);
function getCharacterStats(characterId: string): CharacterStats { if (!characterRow) return null;
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);
let totalDialogueLines = 0; const sceneCharRows = await db.select()
let totalScreenTime = 0; .from(sceneCharacters)
let sceneCount = 0; .where(eq(sceneCharacters.characterId, characterId));
for (const scene of characterScenes) { const relRows = await db.select()
if (scene.content.toLowerCase().includes(characters.get(characterId)?.name.toLowerCase() || '')) { .from(characterRelationships)
sceneCount++; .where(
const dialogueMatches = scene.content.match(/^[A-Z][A-Z\s,.-]+:/gm); or(
if (dialogueMatches) { eq(characterRelationships.characterIdA, characterId),
totalDialogueLines += dialogueMatches.length; eq(characterRelationships.characterIdB, characterId)
} )
totalScreenTime += Math.ceil(scene.content.split('\n').length / 15); );
}
} 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 { return {
characterId, characterId,
totalScreenTime, totalScreenTime,
totalDialogueLines, totalDialogueLines,
sceneCount, sceneCount,
relationshipCount: characterRels.length, relationshipCount: relRows.length,
}; };
} }
// Character search with filtering async function verifyProjectOwnership(
function searchCharacters(projectId: string, query?: string, role?: string, arcType?: string): Character[] { db: DrizzleDB,
let results = Array.from(characters.values()) projectId: number,
.filter(c => c.projectId === projectId); userId: number
) {
const projectRows = await db.select({ id: projects.id, ownerId: projects.ownerId })
.from(projects)
.where(eq(projects.id, projectId));
if (query) { const project = projectRows[0];
const q = query.toLowerCase(); if (!project) {
results = results.filter(c => throw new Error(`Project ${projectId} not found`);
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)
);
} }
if (project.ownerId !== userId) {
if (role) { throw new Error(`You do not have access to project ${projectId}`);
results = results.filter(c => c.role === role);
} }
return project;
if (arcType) {
results = results.filter(c => c.arcType === arcType);
}
return results;
} }
export const projectRouter = { export const projectRouter = {
// Project procedures // Project procedures
listProjects: publicProcedure.query(async ({ ctx }) => { listProjects: protectedProcedure.query(async ({ ctx }) => {
return Array.from(projects.values()); return await ctx.db!.select()
.from(projects)
.where(eq(projects.ownerId, ctx.userId!))
.orderBy(projects.updatedAt);
}), }),
getProject: publicProcedure getProject: protectedProcedure
.input(z.object({ id: z.string().uuid() })) .input(z.object({ id: z.number().int().positive() }))
.query(async ({ input }) => { .query(async ({ input, ctx }) => {
const project = projects.get(input.id); const rows = await ctx.db!.select()
.from(projects)
.where(eq(projects.id, input.id));
const project = rows[0];
if (!project) { if (!project) {
throw new Error(`Project ${input.id} not found`); 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; return project;
}), }),
@@ -97,61 +105,87 @@ export const projectRouter = {
description: z.string().optional(), description: z.string().optional(),
})) }))
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
const project: Project = { const result = await ctx.db!.insert(projects)
id: crypto.randomUUID(), .values({
name: input.name, name: input.name,
description: input.description, description: input.description ?? null,
userId: ctx.userId!, ownerId: ctx.userId!,
createdAt: new Date(), })
updatedAt: new Date(), .returning();
}; return result[0];
projects.set(project.id, project);
return project;
}), }),
updateProject: protectedProcedure updateProject: protectedProcedure
.input(z.object({ .input(z.object({
id: z.string().uuid(), id: z.number().int().positive(),
name: z.string().min(1).max(255).optional(), name: z.string().min(1).max(255).optional(),
description: z.string().optional(), description: z.string().optional(),
})) }))
.mutation(async ({ input }) => { .mutation(async ({ input, ctx }) => {
const project = projects.get(input.id); await verifyProjectOwnership(ctx.db!, input.id, ctx.userId!);
if (!project) {
throw new Error(`Project ${input.id} not found`);
}
const updated: Project = { const updateData: Record<string, any> = { updatedAt: new Date() };
...project, if (input.name !== undefined) updateData.name = input.name;
...(input.name && { name: input.name }), if (input.description !== undefined) updateData.description = input.description ?? null;
...(input.description !== undefined && { description: input.description }),
updatedAt: new Date(),
};
projects.set(updated.id, updated); const result = await ctx.db!.update(projects)
return updated; .set(updateData)
.where(eq(projects.id, input.id))
.returning();
return result[0];
}), }),
deleteProject: protectedProcedure deleteProject: protectedProcedure
.input(z.object({ id: z.string().uuid() })) .input(z.object({ id: z.number().int().positive() }))
.mutation(async ({ input }) => { .mutation(async ({ input, ctx }) => {
const deleted = projects.delete(input.id); await verifyProjectOwnership(ctx.db!, input.id, ctx.userId!);
if (!deleted) {
throw new Error(`Project ${input.id} not found`); // 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 }; return { success: true };
}), }),
// Character CRUD procedures // Character CRUD procedures
listCharacters: projectProcedure.query(async ({ ctx }) => { listCharacters: projectProcedure.query(async ({ ctx }) => {
return Array.from(characters.values()) return await ctx.db!.select()
.filter(char => char.projectId === ctx.projectId); .from(characters)
.where(eq(characters.projectId, ctx.projectId!))
.orderBy(characters.name);
}), }),
getCharacter: publicProcedure getCharacter: protectedProcedure
.input(z.object({ id: z.string().uuid() })) .input(z.object({ id: z.number().int().positive() }))
.query(async ({ input }) => { .query(async ({ input, ctx }) => {
const character = characters.get(input.id); const rows = await ctx.db!.select()
.from(characters)
.where(eq(characters.id, input.id));
const character = rows[0];
if (!character) { if (!character) {
throw new Error(`Character ${input.id} not found`); throw new Error(`Character ${input.id} not found`);
} }
@@ -174,38 +208,37 @@ export const projectRouter = {
conflict: z.string().optional(), conflict: z.string().optional(),
secret: z.string().optional(), secret: z.string().optional(),
imageUrl: z.string().url().optional(), imageUrl: z.string().url().optional(),
projectId: z.string().uuid(), projectId: z.number().int().positive(),
})) }))
.mutation(async ({ input }) => { .mutation(async ({ input, ctx }) => {
const slug = slugify(input.name); await verifyProjectOwnership(ctx.db!, input.projectId, ctx.userId!);
const character: Character = {
id: crypto.randomUUID(), const result = await ctx.db!.insert(characters)
.values({
name: input.name, name: input.name,
slug, slug: slugify(input.name),
description: input.description, description: input.description ?? null,
bio: input.bio, bio: input.bio ?? null,
role: input.role || 'supporting', role: input.role ?? 'supporting',
arc: input.arc, arc: input.arc ?? null,
arcType: input.arcType, arcType: input.arcType ?? null,
age: input.age, age: input.age ?? null,
gender: input.gender, gender: input.gender ?? null,
voice: input.voice, voice: input.voice ?? null,
traits: input.traits, traits: input.traits ?? null,
motivation: input.motivation, motivation: input.motivation ?? null,
conflict: input.conflict, conflict: input.conflict ?? null,
secret: input.secret, secret: input.secret ?? null,
imageUrl: input.imageUrl, imageUrl: input.imageUrl ?? null,
projectId: input.projectId, projectId: input.projectId,
createdAt: new Date(), })
updatedAt: new Date(), .returning();
}; return result[0];
characters.set(character.id, character);
return character;
}), }),
updateCharacter: protectedProcedure updateCharacter: protectedProcedure
.input(z.object({ .input(z.object({
id: z.string().uuid(), id: z.number().int().positive(),
name: z.string().min(1).max(100).optional(), name: z.string().min(1).max(100).optional(),
description: z.string().optional(), description: z.string().optional(),
bio: z.string().optional(), bio: z.string().optional(),
@@ -220,204 +253,296 @@ export const projectRouter = {
conflict: z.string().optional(), conflict: z.string().optional(),
secret: z.string().optional(), secret: z.string().optional(),
imageUrl: z.string().url().optional(), imageUrl: z.string().url().optional(),
projectId: z.string().uuid().optional(), projectId: z.number().int().positive().optional(),
})) }))
.mutation(async ({ input }) => { .mutation(async ({ input, ctx }) => {
const character = characters.get(input.id); const existingRows = await ctx.db!.select()
if (!character) { .from(characters)
.where(eq(characters.id, input.id));
const existing = existingRows[0];
if (!existing) {
throw new Error(`Character ${input.id} not found`); throw new Error(`Character ${input.id} not found`);
} }
await verifyProjectOwnership(ctx.db!, existing.projectId, ctx.userId!);
const updatedName = input.name || character.name; const updateData: Record<string, any> = { updatedAt: new Date() };
const updated: Character = { if (input.name !== undefined) {
...character, updateData.name = input.name;
name: updatedName, updateData.slug = slugify(input.name);
slug: slugify(updatedName), }
...(input.description !== undefined && { description: input.description }), if (input.description !== undefined) updateData.description = input.description ?? null;
...(input.bio !== undefined && { bio: input.bio }), if (input.bio !== undefined) updateData.bio = input.bio ?? null;
...(input.role && { role: input.role }), if (input.role !== undefined) updateData.role = input.role;
...(input.arc !== undefined && { arc: input.arc }), if (input.arc !== undefined) updateData.arc = input.arc ?? null;
...(input.arcType && { arcType: input.arcType }), if (input.arcType !== undefined) updateData.arcType = input.arcType ?? null;
...(input.age !== undefined && { age: input.age }), if (input.age !== undefined) updateData.age = input.age ?? null;
...(input.gender !== undefined && { gender: input.gender }), if (input.gender !== undefined) updateData.gender = input.gender ?? null;
...(input.voice !== undefined && { voice: input.voice }), if (input.voice !== undefined) updateData.voice = input.voice ?? null;
...(input.traits !== undefined && { traits: input.traits }), if (input.traits !== undefined) updateData.traits = input.traits ?? null;
...(input.motivation !== undefined && { motivation: input.motivation }), if (input.motivation !== undefined) updateData.motivation = input.motivation ?? null;
...(input.conflict !== undefined && { conflict: input.conflict }), if (input.conflict !== undefined) updateData.conflict = input.conflict ?? null;
...(input.secret !== undefined && { secret: input.secret }), if (input.secret !== undefined) updateData.secret = input.secret ?? null;
...(input.imageUrl !== undefined && { imageUrl: input.imageUrl }), if (input.imageUrl !== undefined) updateData.imageUrl = input.imageUrl ?? null;
...(input.projectId && { projectId: input.projectId }), if (input.projectId !== undefined) updateData.projectId = input.projectId;
updatedAt: new Date(),
};
characters.set(updated.id, updated); const result = await ctx.db!.update(characters)
return updated; .set(updateData)
.where(eq(characters.id, input.id))
.returning();
return result[0];
}), }),
deleteCharacter: protectedProcedure deleteCharacter: protectedProcedure
.input(z.object({ id: z.string().uuid() })) .input(z.object({ id: z.number().int().positive() }))
.mutation(async ({ input }) => { .mutation(async ({ input, ctx }) => {
const character = characters.get(input.id); const existingRows = await ctx.db!.select()
if (!character) { .from(characters)
.where(eq(characters.id, input.id));
const existing = existingRows[0];
if (!existing) {
throw new Error(`Character ${input.id} not found`); throw new Error(`Character ${input.id} not found`);
} }
await verifyProjectOwnership(ctx.db!, existing.projectId, ctx.userId!);
// Remove associated relationships // Remove associated relationships
for (const [relId, rel] of characterRelationships) { await ctx.db!.delete(characterRelationships)
if (rel.characterIdA === input.id || rel.characterIdB === input.id) { .where(
characterRelationships.delete(relId); or(
} eq(characterRelationships.characterIdA, input.id),
} eq(characterRelationships.characterIdB, input.id)
)
);
await ctx.db!.delete(characters)
.where(eq(characters.id, input.id));
const deleted = characters.delete(input.id);
if (!deleted) {
throw new Error(`Character ${input.id} not found`);
}
return { success: true }; return { success: true };
}), }),
searchCharacters: protectedProcedure searchCharacters: protectedProcedure
.input(z.object({ .input(z.object({
projectId: z.string().uuid(), projectId: z.number().int().positive(),
query: z.string().optional(), query: z.string().optional(),
role: z.enum(['protagonist', 'antagonist', 'supporting', 'background', 'ensemble']).optional(), role: z.enum(['protagonist', 'antagonist', 'supporting', 'background', 'ensemble']).optional(),
arcType: z.enum(['positive', 'negative', 'flat', 'complex']).optional(), arcType: z.enum(['positive', 'negative', 'flat', 'complex']).optional(),
})) }))
.query(async ({ input }) => { .query(async ({ input, ctx }) => {
return searchCharacters(input.projectId, input.query, input.role, input.arcType); 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 getCharacterStats: protectedProcedure
.input(z.object({ characterId: z.string().uuid() })) .input(z.object({ characterId: z.number().int().positive() }))
.query(async ({ input }) => { .query(async ({ input, ctx }) => {
if (!characters.has(input.characterId)) { 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`); throw new Error(`Character ${input.characterId} not found`);
} }
return getCharacterStats(input.characterId); return await getCharacterStatsImpl(ctx.db!, input.characterId);
}), }),
getProjectCharacterStats: projectProcedure getProjectCharacterStats: projectProcedure
.query(async ({ ctx }) => { .query(async ({ ctx }) => {
const projectCharacters = Array.from(characters.values()) const projectCharacters = await ctx.db!.select()
.filter(c => c.projectId === ctx.projectId); .from(characters)
return projectCharacters.map(c => getCharacterStats(c.id)); .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 // Relationship procedures
listRelationships: projectProcedure listRelationships: projectProcedure
.query(async ({ ctx }) => { .query(async ({ ctx }) => {
const projectCharacterIds = new Set( const projectCharacterIds = await ctx.db!.select({ id: characters.id })
Array.from(characters.values()) .from(characters)
.filter(c => c.projectId === ctx.projectId) .where(eq(characters.projectId, ctx.projectId!));
.map(c => c.id) 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)
)
); );
return Array.from(characterRelationships.values())
.filter(r => projectCharacterIds.has(r.characterIdA) && projectCharacterIds.has(r.characterIdB));
}), }),
getRelationshipsForCharacter: protectedProcedure getRelationshipsForCharacter: protectedProcedure
.input(z.object({ characterId: z.string().uuid() })) .input(z.object({ characterId: z.number().int().positive() }))
.query(async ({ input }) => { .query(async ({ input, ctx }) => {
const character = characters.get(input.characterId); const rows = await ctx.db!.select()
if (!character) { .from(characters)
.where(eq(characters.id, input.characterId));
if (!rows[0]) {
throw new Error(`Character ${input.characterId} not found`); throw new Error(`Character ${input.characterId} not found`);
} }
return Array.from(characterRelationships.values()) return await ctx.db!.select()
.filter(r => r.characterIdA === input.characterId || r.characterIdB === input.characterId); .from(characterRelationships)
.where(
or(
eq(characterRelationships.characterIdA, input.characterId),
eq(characterRelationships.characterIdB, input.characterId)
)
);
}), }),
createRelationship: protectedProcedure createRelationship: protectedProcedure
.input(z.object({ .input(z.object({
characterIdA: z.string().uuid(), characterIdA: z.number().int().positive(),
characterIdB: z.string().uuid(), characterIdB: z.number().int().positive(),
relationshipType: z.enum(['family', 'romantic', 'friendship', 'rivalry', 'mentor', 'alliance', 'conflict', 'professional', 'other']), relationshipType: z.enum(['family', 'romantic', 'friendship', 'rivalry', 'mentor', 'alliance', 'conflict', 'professional', 'other']),
description: z.string().optional(), description: z.string().optional(),
strength: z.number().int().min(0).max(100).optional(), strength: z.number().int().min(0).max(100).optional(),
isAntagonistic: z.boolean().optional(), isAntagonistic: z.boolean().optional(),
})) }))
.mutation(async ({ input }) => { .mutation(async ({ input, ctx }) => {
if (input.characterIdA === input.characterIdB) { if (input.characterIdA === input.characterIdB) {
throw new Error('Cannot create a relationship with the same character'); throw new Error('Cannot create a relationship with the same character');
} }
const charA = characters.get(input.characterIdA); const charARows = await ctx.db!.select()
const charB = characters.get(input.characterIdB); .from(characters)
if (!charA || !charB) { .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'); throw new Error('Both characters must exist');
} }
// Check for duplicate relationship const existing = await ctx.db!.select()
const existing = Array.from(characterRelationships.values()).find( .from(characterRelationships)
r => (r.characterIdA === input.characterIdA && r.characterIdB === input.characterIdB) || .where(
(r.characterIdA === input.characterIdB && r.characterIdB === input.characterIdA) 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) { if (existing.length > 0) {
throw new Error('Relationship already exists between these characters'); throw new Error('Relationship already exists between these characters');
} }
const relationship: CharacterRelationship = { const result = await ctx.db!.insert(characterRelationships)
id: crypto.randomUUID(), .values({
characterIdA: input.characterIdA, characterIdA: input.characterIdA,
characterIdB: input.characterIdB, characterIdB: input.characterIdB,
relationshipType: input.relationshipType, relationshipType: input.relationshipType,
description: input.description, description: input.description ?? null,
strength: input.strength ?? 50, strength: input.strength ?? 50,
isAntagonistic: input.isAntagonistic ?? false, isAntagonistic: input.isAntagonistic ?? false,
createdAt: new Date(), })
updatedAt: new Date(), .returning();
}; return result[0];
characterRelationships.set(relationship.id, relationship);
return relationship;
}), }),
updateRelationship: protectedProcedure updateRelationship: protectedProcedure
.input(z.object({ .input(z.object({
id: z.string().uuid(), id: z.number().int().positive(),
relationshipType: z.enum(['family', 'romantic', 'friendship', 'rivalry', 'mentor', 'alliance', 'conflict', 'professional', 'other']).optional(), relationshipType: z.enum(['family', 'romantic', 'friendship', 'rivalry', 'mentor', 'alliance', 'conflict', 'professional', 'other']).optional(),
description: z.string().optional(), description: z.string().optional(),
strength: z.number().int().min(0).max(100).optional(), strength: z.number().int().min(0).max(100).optional(),
isAntagonistic: z.boolean().optional(), isAntagonistic: z.boolean().optional(),
})) }))
.mutation(async ({ input }) => { .mutation(async ({ input, ctx }) => {
const relationship = characterRelationships.get(input.id); const rows = await ctx.db!.select()
if (!relationship) { .from(characterRelationships)
.where(eq(characterRelationships.id, input.id));
if (!rows[0]) {
throw new Error(`Relationship ${input.id} not found`); throw new Error(`Relationship ${input.id} not found`);
} }
const updated: CharacterRelationship = { const updateData: Record<string, any> = { updatedAt: new Date() };
...relationship, if (input.relationshipType !== undefined) updateData.relationshipType = input.relationshipType;
...(input.relationshipType && { relationshipType: input.relationshipType }), if (input.description !== undefined) updateData.description = input.description ?? null;
...(input.description !== undefined && { description: input.description }), if (input.strength !== undefined) updateData.strength = input.strength;
...(input.strength !== undefined && { strength: input.strength }), if (input.isAntagonistic !== undefined) updateData.isAntagonistic = input.isAntagonistic;
...(input.isAntagonistic !== undefined && { isAntagonistic: input.isAntagonistic }),
updatedAt: new Date(),
};
characterRelationships.set(updated.id, updated); const result = await ctx.db!.update(characterRelationships)
return updated; .set(updateData)
.where(eq(characterRelationships.id, input.id))
.returning();
return result[0];
}), }),
deleteRelationship: protectedProcedure deleteRelationship: protectedProcedure
.input(z.object({ id: z.string().uuid() })) .input(z.object({ id: z.number().int().positive() }))
.mutation(async ({ input }) => { .mutation(async ({ input, ctx }) => {
const deleted = characterRelationships.delete(input.id); const rows = await ctx.db!.select()
if (!deleted) { .from(characterRelationships)
.where(eq(characterRelationships.id, input.id));
if (!rows[0]) {
throw new Error(`Relationship ${input.id} not found`); throw new Error(`Relationship ${input.id} not found`);
} }
await ctx.db!.delete(characterRelationships)
.where(eq(characterRelationships.id, input.id));
return { success: true }; return { success: true };
}), }),
// Scene procedures // Scene procedures
listScenes: projectProcedure.query(async ({ ctx }) => { listScenes: projectProcedure.query(async ({ ctx }) => {
return Array.from(scenes.values()) return await ctx.db!.select()
.filter(scene => scene.projectId === ctx.projectId) .from(scenes)
.sort((a, b) => a.order - b.order); .where(eq(scenes.projectId, ctx.projectId!))
.orderBy(scenes.order);
}), }),
getScene: publicProcedure getScene: protectedProcedure
.input(z.object({ id: z.string().uuid() })) .input(z.object({ id: z.number().int().positive() }))
.query(async ({ input }) => { .query(async ({ input, ctx }) => {
const scene = scenes.get(input.id); const rows = await ctx.db!.select()
.from(scenes)
.where(eq(scenes.id, input.id));
const scene = rows[0];
if (!scene) { if (!scene) {
throw new Error(`Scene ${input.id} not found`); throw new Error(`Scene ${input.id} not found`);
} }
@@ -428,55 +553,63 @@ export const projectRouter = {
.input(z.object({ .input(z.object({
title: z.string().min(1), title: z.string().min(1),
content: z.string().optional(), content: z.string().optional(),
projectId: z.string().uuid(), projectId: z.number().int().positive(),
order: z.number().int().nonnegative(), order: z.number().int().nonnegative(),
})) }))
.mutation(async ({ input }) => { .mutation(async ({ input, ctx }) => {
const scene: Scene = { await verifyProjectOwnership(ctx.db!, input.projectId, ctx.userId!);
id: crypto.randomUUID(),
const result = await ctx.db!.insert(scenes)
.values({
title: input.title, title: input.title,
content: input.content || '', content: input.content ?? '',
projectId: input.projectId, projectId: input.projectId,
order: input.order, order: input.order,
createdAt: new Date(), })
updatedAt: new Date(), .returning();
}; return result[0];
scenes.set(scene.id, scene);
return scene;
}), }),
updateScene: protectedProcedure updateScene: protectedProcedure
.input(z.object({ .input(z.object({
id: z.string().uuid(), id: z.number().int().positive(),
title: z.string().min(1).optional(), title: z.string().min(1).optional(),
content: z.string().optional(), content: z.string().optional(),
order: z.number().int().nonnegative().optional(), order: z.number().int().nonnegative().optional(),
})) }))
.mutation(async ({ input }) => { .mutation(async ({ input, ctx }) => {
const scene = scenes.get(input.id); const rows = await ctx.db!.select()
if (!scene) { .from(scenes)
.where(eq(scenes.id, input.id));
if (!rows[0]) {
throw new Error(`Scene ${input.id} not found`); throw new Error(`Scene ${input.id} not found`);
} }
const updated: Scene = { const updateData: Record<string, any> = { updatedAt: new Date() };
...scene, if (input.title !== undefined) updateData.title = input.title;
...(input.title && { title: input.title }), if (input.content !== undefined) updateData.content = input.content ?? '';
...(input.content !== undefined && { content: input.content }), if (input.order !== undefined) updateData.order = input.order;
...(input.order !== undefined && { order: input.order }),
updatedAt: new Date(),
};
scenes.set(updated.id, updated); const result = await ctx.db!.update(scenes)
return updated; .set(updateData)
.where(eq(scenes.id, input.id))
.returning();
return result[0];
}), }),
deleteScene: protectedProcedure deleteScene: protectedProcedure
.input(z.object({ id: z.string().uuid() })) .input(z.object({ id: z.number().int().positive() }))
.mutation(async ({ input }) => { .mutation(async ({ input, ctx }) => {
const deleted = scenes.delete(input.id); const rows = await ctx.db!.select()
if (!deleted) { .from(scenes)
.where(eq(scenes.id, input.id));
if (!rows[0]) {
throw new Error(`Scene ${input.id} not found`); throw new Error(`Scene ${input.id} not found`);
} }
await ctx.db!.delete(scenes)
.where(eq(scenes.id, input.id));
return { success: true }; return { success: true };
}), }),
}; };

View File

@@ -151,6 +151,15 @@ export const CharacterList: Component<CharacterListProps> = (props) => {
</div> </div>
</Show> </Show>
<Show when={charactersQuery.isLoading()}>
<div class="loading-state">Loading characters...</div>
</Show>
<Show when={charactersQuery.error()}>
<div class="error-state">
<p>Error loading characters: {charactersQuery.error()?.message}</p>
<button onClick={() => charactersQuery.refetch()}>Retry</button>
</div>
</Show>
<div class="character-grid"> <div class="character-grid">
<For each={charactersQuery.data()}> <For each={charactersQuery.data()}>
{(character) => ( {(character) => (

View File

@@ -21,6 +21,7 @@ export const CharacterProfile: Component<CharacterProfileProps> = (props) => {
}; };
const handleDelete = async () => { 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); await deleteCharacter.mutateAsync(props.character.id);
props.onClose?.(); props.onClose?.();
}; };

View File

@@ -119,7 +119,10 @@ export const CharacterRelationships: Component<CharacterRelationshipsProps> = (p
</Show> </Show>
</div> </div>
<button <button
onClick={() => deleteRelationship.mutateAsync(rel.id)} onClick={() => {
if (!confirm('Are you sure you want to remove this relationship?')) return;
deleteRelationship.mutateAsync(rel.id);
}}
class="delete-relationship-btn" class="delete-relationship-btn"
> >
Remove Remove

View File

@@ -53,6 +53,15 @@ export const CharacterSearch: Component<CharacterSearchProps> = (props) => {
<button onClick={handleSearch} class="search-btn">Search</button> <button onClick={handleSearch} class="search-btn">Search</button>
<button onClick={handleClear} class="clear-btn">Clear</button> <button onClick={handleClear} class="clear-btn">Clear</button>
</div> </div>
<Show when={results.isLoading()}>
<div class="loading-state">Searching...</div>
</Show>
<Show when={results.error()}>
<div class="error-state">
<p>Error: {results.error()?.message}</p>
<button onClick={() => results.refetch()}>Retry</button>
</div>
</Show>
<div class="search-results"> <div class="search-results">
<For each={results.data()}> <For each={results.data()}>
{(character) => ( {(character) => (

View File

@@ -18,6 +18,15 @@ export const CharacterStatsPanel: Component<CharacterStatsPanelProps> = (props)
return ( return (
<div class="character-stats-panel"> <div class="character-stats-panel">
<h3>Character Statistics</h3> <h3>Character Statistics</h3>
<Show when={stats.isLoading()}>
<div class="loading-state">Loading statistics...</div>
</Show>
<Show when={stats.error()}>
<div class="error-state">
<p>Error loading stats: {stats.error()?.message}</p>
<button onClick={() => stats.refetch()}>Retry</button>
</div>
</Show>
<Show when={stats.data() && stats.data()!.length > 0}> <Show when={stats.data() && stats.data()!.length > 0}>
<div class="stats-table-container"> <div class="stats-table-container">
<table class="stats-table"> <table class="stats-table">

View File

@@ -1,11 +1,11 @@
import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core"; import { sqliteTable, text, integer, uniqueIndex } from "drizzle-orm/sqlite-core";
import { scripts } from "./scripts"; import { projects } from "./projects";
export const characters = sqliteTable("characters", { export const characters = sqliteTable("characters", {
id: integer("id").primaryKey({ autoIncrement: true }), id: integer("id").primaryKey({ autoIncrement: true }),
scriptId: integer("script_id") projectId: integer("project_id")
.notNull() .notNull()
.references(() => scripts.id), .references(() => projects.id),
name: text("name").notNull(), name: text("name").notNull(),
slug: text("slug").notNull(), slug: text("slug").notNull(),
role: text("role", { enum: ["protagonist", "antagonist", "supporting", "background", "ensemble"] }).notNull().default("supporting"), 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), isAntagonistic: integer("is_antagonistic", { mode: "boolean" }).notNull().default(false),
createdAt: integer("created_at", { mode: "timestamp" }).$defaultFn(() => new Date()), createdAt: integer("created_at", { mode: "timestamp" }).$defaultFn(() => new Date()),
updatedAt: integer("updated_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 Character = typeof characters.$inferSelect;
export type NewCharacter = typeof characters.$inferInsert; export type NewCharacter = typeof characters.$inferInsert;