diff --git a/server/trpc/character-router.test.ts b/server/trpc/character-router.test.ts new file mode 100644 index 000000000..3ca641595 --- /dev/null +++ b/server/trpc/character-router.test.ts @@ -0,0 +1,256 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { appRouter } from './index'; + +describe('tRPC API Layer - Character System', () => { + let ctx: { userId: string }; + let projectId: string; + + beforeEach(async () => { + ctx = { userId: '123e4567-e89b-12d3-a456-426614174000' }; + const project = await appRouter.project.createProject.mutate({ + input: { name: 'Character System Test Project' }, + ctx, + }); + projectId = project.id; + }); + + describe('createCharacter', () => { + it('should create a character with all profile fields', async () => { + const character = await appRouter.project.createCharacter.mutate({ + input: { + name: 'John Doe', + bio: 'A brave hero', + role: 'protagonist', + arc: 'Grows from coward to leader', + arcType: 'positive', + age: 30, + gender: 'male', + voice: 'Deep, commanding', + traits: 'Brave, loyal, stubborn', + motivation: 'Protect his family', + conflict: 'Internal fear of failure', + secret: 'Afraid of heights', + projectId, + }, + ctx, + }); + + expect(character).toMatchObject({ + name: 'John Doe', + bio: 'A brave hero', + role: 'protagonist', + arcType: 'positive', + age: 30, + projectId, + }); + expect(character.slug).toBe('john-doe'); + }); + + it('should default role to supporting when not provided', async () => { + const character = await appRouter.project.createCharacter.mutate({ + input: { + name: 'Jane Smith', + projectId, + }, + ctx, + }); + + expect(character.role).toBe('supporting'); + }); + }); + + describe('updateCharacter', () => { + it('should update character profile fields', async () => { + const created = await appRouter.project.createCharacter.mutate({ + input: { name: 'Original', projectId }, + ctx, + }); + + const updated = await appRouter.project.updateCharacter.mutate({ + input: { + id: created.id, + name: 'Updated Name', + bio: 'New bio', + role: 'antagonist', + }, + ctx, + }); + + expect(updated.name).toBe('Updated Name'); + expect(updated.slug).toBe('updated-name'); + expect(updated.bio).toBe('New bio'); + expect(updated.role).toBe('antagonist'); + }); + }); + + describe('searchCharacters', () => { + it('should filter characters by query', async () => { + await appRouter.project.createCharacter.mutate({ + input: { name: 'Alice', bio: 'The hero', projectId }, + ctx, + }); + await appRouter.project.createCharacter.mutate({ + input: { name: 'Bob', bio: 'The villain', projectId }, + ctx, + }); + + const results = await appRouter.project.searchCharacters.query({ + input: { projectId, query: 'hero' }, + ctx, + }); + + expect(results.length).toBe(1); + expect(results[0].name).toBe('Alice'); + }); + + it('should filter characters by role', async () => { + await appRouter.project.createCharacter.mutate({ + input: { name: 'Protag', role: 'protagonist', projectId }, + ctx, + }); + await appRouter.project.createCharacter.mutate({ + input: { name: 'Antag', role: 'antagonist', projectId }, + ctx, + }); + + const results = await appRouter.project.searchCharacters.query({ + input: { projectId, role: 'protagonist' }, + ctx, + }); + + expect(results.length).toBe(1); + expect(results[0].name).toBe('Protag'); + }); + }); + + describe('createRelationship', () => { + it('should create a relationship between two characters', async () => { + const charA = await appRouter.project.createCharacter.mutate({ + input: { name: 'Character A', projectId }, + ctx, + }); + const charB = await appRouter.project.createCharacter.mutate({ + input: { name: 'Character B', projectId }, + ctx, + }); + + const rel = await appRouter.project.createRelationship.mutate({ + input: { + characterIdA: charA.id, + characterIdB: charB.id, + relationshipType: 'friendship', + strength: 80, + isAntagonistic: false, + }, + ctx, + }); + + expect(rel.characterIdA).toBe(charA.id); + expect(rel.characterIdB).toBe(charB.id); + expect(rel.relationshipType).toBe('friendship'); + expect(rel.strength).toBe(80); + }); + + it('should prevent self-relationships', async () => { + const charA = await appRouter.project.createCharacter.mutate({ + input: { name: 'Character A', projectId }, + ctx, + }); + + await expect( + appRouter.project.createRelationship.mutate({ + input: { + characterIdA: charA.id, + characterIdB: charA.id, + relationshipType: 'friendship', + }, + ctx, + }) + ).rejects.toThrow('Cannot create a relationship with the same character'); + }); + + it('should prevent duplicate relationships', async () => { + const charA = await appRouter.project.createCharacter.mutate({ + input: { name: 'Character A', projectId }, + ctx, + }); + const charB = await appRouter.project.createCharacter.mutate({ + input: { name: 'Character B', projectId }, + ctx, + }); + + await appRouter.project.createRelationship.mutate({ + input: { + characterIdA: charA.id, + characterIdB: charB.id, + relationshipType: 'friendship', + }, + ctx, + }); + + await expect( + appRouter.project.createRelationship.mutate({ + input: { + characterIdA: charA.id, + characterIdB: charB.id, + relationshipType: 'rivalry', + }, + ctx, + }) + ).rejects.toThrow('Relationship already exists between these characters'); + }); + }); + + describe('deleteCharacter', () => { + it('should remove associated relationships when deleting a character', async () => { + const charA = await appRouter.project.createCharacter.mutate({ + input: { name: 'Character A', projectId }, + ctx, + }); + const charB = await appRouter.project.createCharacter.mutate({ + input: { name: 'Character B', projectId }, + ctx, + }); + + await appRouter.project.createRelationship.mutate({ + input: { + characterIdA: charA.id, + characterIdB: charB.id, + relationshipType: 'friendship', + }, + ctx, + }); + + await appRouter.project.deleteCharacter.mutate({ + input: { id: charA.id }, + ctx, + }); + + const rels = await appRouter.project.getRelationshipsForCharacter.query({ + input: { characterId: charB.id }, + ctx, + }); + + expect(rels.length).toBe(0); + }); + }); + + describe('getCharacterStats', () => { + it('should return stats for a character', async () => { + const charA = await appRouter.project.createCharacter.mutate({ + input: { name: 'TestChar', projectId }, + ctx, + }); + + const stats = await appRouter.project.getCharacterStats.query({ + input: { characterId: charA.id }, + ctx, + }); + + expect(stats.characterId).toBe(charA.id); + expect(stats.sceneCount).toBe(0); + expect(stats.totalDialogueLines).toBe(0); + expect(stats.relationshipCount).toBe(0); + }); + }); +}); diff --git a/server/trpc/index.ts b/server/trpc/index.ts new file mode 100644 index 000000000..95b6b2478 --- /dev/null +++ b/server/trpc/index.ts @@ -0,0 +1,41 @@ +import { initHTTPServer } from '@trpc/server/adapters/http'; +import { projectRouter } from './project-router'; +import type { TRPCContext } from './types'; +import type { TRPCError } from '@trpc/server'; + +// App router combining all routers +export const appRouter = { + project: projectRouter, +}; + +export type AppRouter = typeof appRouter; + +// Create tRPC HTTP server +export function createTRPCServer(port: number = 8080) { + const server = initHTTPServer({ + router: appRouter, + createContext: async ({ req }: { req: Request }): Promise => { + // Extract auth from headers + const authHeader = req.headers.get('authorization'); + const userId = authHeader?.split(' ')[1]; // Bearer token + + return { + userId, + }; + }, + onError: ({ error, path, input }: { error: TRPCError; path: string; input: unknown }) => { + console.error(`tRPC error on ${path}:`, { + input, + error: error.message, + }); + }, + }); + + server.listen(port, () => { + console.log(`tRPC server listening on port ${port}`); + }); + + return server; +} + +export default appRouter; diff --git a/server/trpc/project-router.test.ts b/server/trpc/project-router.test.ts new file mode 100644 index 000000000..77f92954b --- /dev/null +++ b/server/trpc/project-router.test.ts @@ -0,0 +1,211 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { appRouter } from './index'; +import type { Project } from '../../server/types/project'; + +describe('tRPC API Layer', () => { + let ctx: { userId: string }; + + beforeEach(() => { + ctx = { userId: '123e4567-e89b-12d3-a456-426614174000' }; + }); + + describe('Project CRUD', () => { + it('should create a project', async () => { + const project = await appRouter.project.createProject.mutate({ + input: { + name: 'Test Project', + description: 'A test project', + }, + ctx, + }); + + expect(project).toMatchObject({ + name: 'Test Project', + description: 'A test project', + userId: ctx.userId, + }); + expect(project.id).toBeDefined(); + expect(project.createdAt).toBeInstanceOf(Date); + expect(project.updatedAt).toBeInstanceOf(Date); + }); + + it('should list projects', async () => { + const projects = await appRouter.project.listProjects.query({ + ctx: { userId: ctx.userId }, + }); + + expect(Array.isArray(projects)).toBe(true); + }); + + it('should get a specific project', async () => { + // First create a project + const created = await appRouter.project.createProject.mutate({ + input: { name: 'Get Test' }, + ctx, + }); + + const project = await appRouter.project.getProject.query({ + input: { id: created.id }, + ctx, + }); + + expect(project.id).toBe(created.id); + expect(project.name).toBe('Get Test'); + }); + + it('should update a project', async () => { + const created = await appRouter.project.createProject.mutate({ + input: { name: 'Update Test' }, + ctx, + }); + + const updated = await appRouter.project.updateProject.mutate({ + input: { + id: created.id, + name: 'Updated Test', + description: 'Updated description', + }, + ctx, + }); + + expect(updated.name).toBe('Updated Test'); + expect(updated.description).toBe('Updated description'); + }); + + it('should delete a project', async () => { + const created = await appRouter.project.createProject.mutate({ + input: { name: 'Delete Test' }, + ctx, + }); + + const result = await appRouter.project.deleteProject.mutate({ + input: { id: created.id }, + ctx, + }); + + expect(result).toEqual({ success: true }); + }); + }); + + describe('Character CRUD', () => { + let projectId: string; + + beforeEach(async () => { + const project = await appRouter.project.createProject.mutate({ + input: { name: 'Character Test Project' }, + ctx, + }); + projectId = project.id; + }); + + it('should create a character', async () => { + const character = await appRouter.project.createCharacter.mutate({ + input: { + name: 'John Doe', + description: 'Main character', + projectId, + }, + ctx, + }); + + expect(character).toMatchObject({ + name: 'John Doe', + description: 'Main character', + projectId, + }); + }); + + it('should list characters for a project', async () => { + await appRouter.project.createCharacter.mutate({ + input: { name: 'Char 1', projectId }, + ctx, + }); + + const characters = await appRouter.project.listCharacters.query({ + input: { projectId }, + ctx, + }); + + expect(characters.length).toBeGreaterThan(0); + }); + }); + + describe('Scene CRUD', () => { + let projectId: string; + + beforeEach(async () => { + const project = await appRouter.project.createProject.mutate({ + input: { name: 'Scene Test Project' }, + ctx, + }); + projectId = project.id; + }); + + it('should create a scene', async () => { + const scene = await appRouter.project.createScene.mutate({ + input: { + title: 'INT. OFFICE - DAY', + content: 'John sits at his desk.', + projectId, + order: 1, + }, + ctx, + }); + + expect(scene).toMatchObject({ + title: 'INT. OFFICE - DAY', + content: 'John sits at his desk.', + projectId, + order: 1, + }); + }); + + it('should list scenes for a project', async () => { + await appRouter.project.createScene.mutate({ + input: { title: 'Scene 1', projectId, order: 1 }, + ctx, + }); + + const scenes = await appRouter.project.listScenes.query({ + input: { projectId }, + ctx, + }); + + expect(scenes.length).toBeGreaterThan(0); + }); + + it('should update scene order', async () => { + const scene = await appRouter.project.createScene.mutate({ + input: { title: 'Reorder Scene', projectId, order: 1 }, + ctx, + }); + + const updated = await appRouter.project.updateScene.mutate({ + input: { id: scene.id, order: 5 }, + ctx, + }); + + expect(updated.order).toBe(5); + }); + }); + + describe('Error Handling', () => { + it('should throw error when getting non-existent project', async () => { + await expect( + appRouter.project.getProject.query({ + input: { id: '00000000-0000-0000-0000-000000000000' }, + ctx, + }) + ).rejects.toThrow('not found'); + }); + + it('should throw error when deleting non-existent project', async () => { + const result = await appRouter.project.deleteProject.mutate({ + input: { id: '00000000-0000-0000-0000-000000000000' }, + ctx, + }); + + expect(result).toEqual({ success: false }); + }); + }); +}); diff --git a/server/trpc/project-router.ts b/server/trpc/project-router.ts new file mode 100644 index 000000000..7c2b83ad4 --- /dev/null +++ b/server/trpc/project-router.ts @@ -0,0 +1,482 @@ +import { publicProcedure, protectedProcedure, projectProcedure } from './router'; +import { z } from 'zod'; +import type { Project, Character, Scene, CharacterRelationship, CharacterStats } from '../types/project'; + +// 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; +} + +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); + + let totalDialogueLines = 0; + let totalScreenTime = 0; + let sceneCount = 0; + + 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); + } + } + + return { + characterId, + totalScreenTime, + totalDialogueLines, + sceneCount, + relationshipCount: characterRels.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); + + 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) + ); + } + + if (role) { + results = results.filter(c => c.role === role); + } + + if (arcType) { + results = results.filter(c => c.arcType === arcType); + } + + return results; +} + +export const projectRouter = { + // Project procedures + listProjects: publicProcedure.query(async ({ ctx }) => { + return Array.from(projects.values()); + }), + + 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(), + 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); + }), + + 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; + }), + + 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(), + 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, + projectId: input.projectId, + createdAt: new Date(), + updatedAt: new Date(), + }; + characters.set(character.id, character); + return character; + }), + + 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`); + } + + 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(), + }; + + characters.set(updated.id, updated); + return updated; + }), + + 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`); + } + + // 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) + ); + 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); + }), + + 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'); + } + + const charA = characters.get(input.characterIdA); + const charB = characters.get(input.characterIdB); + if (!charA || !charB) { + throw new Error('Both characters must exist'); + } + + // 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) + ); + if (existing) { + throw new Error('Relationship already exists between these characters'); + } + + const relationship: CharacterRelationship = { + id: crypto.randomUUID(), + characterIdA: input.characterIdA, + characterIdB: input.characterIdB, + relationshipType: input.relationshipType, + description: input.description, + 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); + }), + + 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; + }), + + 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(), + title: input.title, + content: input.content || '', + projectId: input.projectId, + order: input.order, + createdAt: new Date(), + updatedAt: new Date(), + }; + scenes.set(scene.id, scene); + return scene; + }), + + 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`); + } + + const updated: Scene = { + ...scene, + ...(input.title && { title: input.title }), + ...(input.content !== undefined && { content: input.content }), + ...(input.order !== undefined && { order: input.order }), + updatedAt: new Date(), + }; + + scenes.set(updated.id, updated); + return updated; + }), + + 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 }; + }), +}; diff --git a/server/trpc/router.ts b/server/trpc/router.ts new file mode 100644 index 000000000..ccb2a8d5e --- /dev/null +++ b/server/trpc/router.ts @@ -0,0 +1,41 @@ +import { initTRPC, TRPCError } from '@trpc/server'; +import { z } from 'zod'; +import type { TRPCContext } from './types'; + +// Initialize tRPC with context +const t = initTRPC.context().create(); + +// Middleware for authentication +const isAuthenticated = t.middleware(({ ctx, next }) => { + if (!ctx.userId) { + throw new TRPCError({ code: 'UNAUTHORIZED', message: 'User not authenticated' }); + } + return next({ ctx: { ...ctx, userId: ctx.userId } }); +}); + +// Middleware for project authorization +const hasProjectAccess = t.middleware(({ ctx, next }) => { + const projectId = ctx.projectId; + if (!projectId) { + throw new TRPCError({ code: 'FORBIDDEN', message: 'Project access required' }); + } + return next({ ctx: { ...ctx, projectId } }); +}); + +// Base router +export const baseRouter = t.router; + +// Procedure builders +export const publicProcedure = t.procedure; +export const protectedProcedure = t.procedure.use(isAuthenticated); +export const projectProcedure = t.procedure.use(hasProjectAccess); + +// Validation middleware +export const validateInput = (schema: T) => { + return t.middleware(({ input, next }) => { + const validated = schema.parse(input); + return next({ input: validated }); + }); +}; + +export { t, TRPCError }; diff --git a/server/trpc/types.ts b/server/trpc/types.ts new file mode 100644 index 000000000..cede72b7a --- /dev/null +++ b/server/trpc/types.ts @@ -0,0 +1,172 @@ +import { z } from 'zod'; + +// Base types +export const ProjectSchema = z.object({ + id: z.string().uuid(), + name: z.string().min(1).max(255), + description: z.string().optional(), + userId: z.string().uuid(), + createdAt: z.date(), + updatedAt: z.date(), +}); + +export const CharacterSchema = z.object({ + id: z.string().uuid(), + name: z.string().min(1).max(100), + slug: z.string(), + 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(), + createdAt: z.date(), + updatedAt: z.date(), +}); + +export const CharacterRelationshipSchema = z.object({ + id: z.string().uuid(), + 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), + isAntagonistic: z.boolean(), + createdAt: z.date(), + updatedAt: z.date(), +}); + +export const CharacterStatsSchema = z.object({ + characterId: z.string().uuid(), + totalScreenTime: z.number().int(), + totalDialogueLines: z.number().int(), + sceneCount: z.number().int(), + relationshipCount: z.number().int(), +}); + +export const SceneSchema = z.object({ + id: z.string().uuid(), + title: z.string().min(1), + content: z.string(), + projectId: z.string().uuid(), + order: z.number().int().nonnegative(), + createdAt: z.date(), + updatedAt: z.date(), +}); + +export const ScriptVersionSchema = z.object({ + id: z.string().uuid(), + projectId: z.string().uuid(), + content: z.string(), + version: z.number().int().positive(), + authorId: z.string().uuid().optional(), + createdAt: z.date(), +}); + +// Input schemas +export const CreateProjectInputSchema = z.object({ + name: z.string().min(1).max(255), + description: z.string().optional(), +}); + +export const UpdateProjectInputSchema = z.object({ + id: z.string().uuid(), + name: z.string().min(1).max(255).optional(), + description: z.string().optional(), +}); + +export const CreateCharacterInputSchema = 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(), +}); + +export const UpdateCharacterInputSchema = 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(), +}); + +export const CreateRelationshipInputSchema = 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(), +}); + +export const UpdateRelationshipInputSchema = 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(), +}); + +export const SearchCharactersInputSchema = 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(), +}); + +export const CreateSceneInputSchema = z.object({ + title: z.string().min(1), + content: z.string().optional(), + projectId: z.string().uuid(), + order: z.number().int().nonnegative(), +}); + +export const UpdateSceneInputSchema = z.object({ + id: z.string().uuid(), + title: z.string().min(1).optional(), + content: z.string().optional(), + order: z.number().int().nonnegative().optional(), +}); + +// Response schemas +export const ProjectListSchema = z.array(ProjectSchema); +export const CharacterListSchema = z.array(CharacterSchema); +export const CharacterRelationshipListSchema = z.array(CharacterRelationshipSchema); +export const CharacterStatsListSchema = z.array(CharacterStatsSchema); +export const SceneListSchema = z.array(SceneSchema); + +// Auth context +export interface TRPCContext { + userId?: string; + projectId?: string; +} diff --git a/server/types/project.ts b/server/types/project.ts new file mode 100644 index 000000000..d1e85c57e --- /dev/null +++ b/server/types/project.ts @@ -0,0 +1,35 @@ +import type { z } from 'zod'; +import { + ProjectSchema, + CharacterSchema, + CharacterRelationshipSchema, + CharacterStatsSchema, + SceneSchema, + ScriptVersionSchema, + CreateProjectInputSchema, + UpdateProjectInputSchema, + CreateCharacterInputSchema, + UpdateCharacterInputSchema, + CreateRelationshipInputSchema, + UpdateRelationshipInputSchema, + SearchCharactersInputSchema, + CreateSceneInputSchema, + UpdateSceneInputSchema, +} from '../trpc/types'; + +export type Project = z.infer; +export type Character = z.infer; +export type CharacterRelationship = z.infer; +export type CharacterStats = z.infer; +export type Scene = z.infer; +export type ScriptVersion = z.infer; + +export type CreateProjectInput = z.infer; +export type UpdateProjectInput = z.infer; +export type CreateCharacterInput = z.infer; +export type UpdateCharacterInput = z.infer; +export type CreateRelationshipInput = z.infer; +export type UpdateRelationshipInput = z.infer; +export type SearchCharactersInput = z.infer; +export type CreateSceneInput = z.infer; +export type UpdateSceneInput = z.infer; diff --git a/src/components/characters/CharacterCard.tsx b/src/components/characters/CharacterCard.tsx new file mode 100644 index 000000000..cec1e3af3 --- /dev/null +++ b/src/components/characters/CharacterCard.tsx @@ -0,0 +1,64 @@ +import { Component, For, Show } from 'solid-js'; +import type { Character } from '../../../../server/types/project'; + +export interface CharacterCardProps { + character: Character; + onSelect?: (character: Character) => void; + compact?: boolean; +} + +const roleColors: Record = { + protagonist: '#4CAF50', + antagonist: '#F44336', + supporting: '#2196F3', + background: '#9E9E9E', + ensemble: '#9C27B0', +}; + +export const CharacterCard: Component = (props) => { + const handleClick = () => { + props.onSelect?.(props.character); + }; + + return ( +
+
+ + {props.character.name} + + +
+ {props.character.name.charAt(0).toUpperCase()} +
+
+
+

{props.character.name}

+ + {props.character.role} + +
+
+ +

{props.character.bio}

+
+ +
+ Traits: {props.character.traits} +
+
+
+ ); +}; diff --git a/src/components/characters/CharacterList.tsx b/src/components/characters/CharacterList.tsx new file mode 100644 index 000000000..ef34810da --- /dev/null +++ b/src/components/characters/CharacterList.tsx @@ -0,0 +1,173 @@ +import { Component, createSignal, For, Show } from 'solid-js'; +import { + useCharacters, + useCreateCharacter, + useUpdateCharacter, + useDeleteCharacter, +} from '../../../lib/api/trpc-hooks'; +import { CharacterCard } from './CharacterCard'; +import { CharacterProfile } from './CharacterProfile'; +import type { Character } from '../../../../server/types/project'; + +export interface CharacterListProps { + projectId: string; +} + +export const CharacterList: Component = (props) => { + const charactersQuery = useCharacters(props.projectId); + const createCharacter = useCreateCharacter(); + const [selectedCharacter, setSelectedCharacter] = createSignal(null); + const [showForm, setShowForm] = createSignal(false); + + const [formData, setFormData] = createSignal({ + name: '', + description: '', + bio: '', + role: 'supporting' as 'protagonist' | 'antagonist' | 'supporting' | 'background' | 'ensemble', + arc: '', + arcType: undefined as 'positive' | 'negative' | 'flat' | 'complex' | undefined, + age: undefined as number | undefined, + gender: '', + voice: '', + traits: '', + motivation: '', + conflict: '', + secret: '', + }); + + const handleCreate = async () => { + if (!formData().name.trim()) return; + await createCharacter.mutateAsync({ + ...formData(), + projectId: props.projectId, + }); + setShowForm(false); + setFormData({ + name: '', + description: '', + bio: '', + role: 'supporting', + arc: '', + arcType: undefined, + age: undefined, + gender: '', + voice: '', + traits: '', + motivation: '', + conflict: '', + secret: '', + }); + }; + + return ( +
+
+

Characters

+ +
+ + +
+
+ setFormData({ ...formData(), name: e.currentTarget.value })} + required + /> + +
+