diff --git a/package.json b/package.json index 58c792f89..219fe5492 100644 --- a/package.json +++ b/package.json @@ -12,25 +12,49 @@ "server:dev": "tsx watch server/websocket/index.ts", "server:build": "tsc -p tsconfig.server.json", "lint": "eslint src/ server/", - "lint:fix": "eslint src/ server/ --fix" + "lint:fix": "eslint src/ server/ --fix", + "db:generate": "drizzle-kit generate", + "db:push": "drizzle-kit push", + "db:migrate": "drizzle-kit migrate", + "db:seed": "tsx src/db/seed.ts", + "tauri:dev": "tauri dev", + "tauri:build": "tauri build", + "tauri:build:macos": "TAURI_TARGET=x86_64-apple-darwin tauri build", + "tauri:build:windows": "TAURI_TARGET=x86_64-pc-windows-msvc tauri build", + "tauri:build:linux": "TAURI_TARGET=x86_64-unknown-linux-gnu tauri build" }, "dependencies": { + "@libsql/client": "^0.17.3", + "@tanstack/react-query": "^5.100.1", + "@tanstack/solid-query": "^5.100.1", + "@trpc/client": "^11.16.0", + "@trpc/react-query": "^11.16.0", + "@trpc/server": "^11.16.0", + "@types/node": "^25.6.0", + "@types/peerjs": "^0.0.30", + "drizzle-kit": "^0.31.10", + "drizzle-orm": "^0.45.2", + "peerjs": "^1.5.5", "solid-js": "^1.8.14", - "yjs": "^13.6.12", + "ws": "^8.16.0", "y-websocket": "^1.5.0", - "ws": "^8.16.0" + "yjs": "^13.6.12", + "zod": "^4.3.6" }, "devDependencies": { + "@tauri-apps/cli": "^2.1.0", + "@types/better-sqlite3": "^7.6.13", "@types/ws": "^8.5.10", "@typescript-eslint/eslint-plugin": "^7.0.2", "@typescript-eslint/parser": "^7.0.2", + "better-sqlite3": "^12.9.0", "eslint": "^8.56.0", "eslint-plugin-solid": "^0.13.2", + "tsx": "^4.7.1", "typescript": "^5.3.3", "vite": "^5.1.4", "vite-plugin-solid": "^2.8.2", - "vitest": "^1.3.1", - "tsx": "^4.7.1" + "vitest": "^1.3.1" }, "engines": { "node": ">=18.0.0" diff --git a/server/trpc/character-router.test.ts b/server/trpc/character-router.test.ts index 3ca641595..46e218792 100644 --- a/server/trpc/character-router.test.ts +++ b/server/trpc/character-router.test.ts @@ -1,38 +1,41 @@ import { describe, it, expect, beforeEach } from 'vitest'; import { appRouter } from './index'; +import { getTestDb, resetTestDb } from './test-setup'; +import type { TRPCContext } from './types'; describe('tRPC API Layer - Character System', () => { - let ctx: { userId: string }; - let projectId: string; + let ctx: TRPCContext; + let caller: ReturnType; + let projectId: number; beforeEach(async () => { - ctx = { userId: '123e4567-e89b-12d3-a456-426614174000' }; - const project = await appRouter.project.createProject.mutate({ - input: { name: 'Character System Test Project' }, - ctx, + await resetTestDb(); + const db = await getTestDb(); + ctx = { userId: 1, db }; + caller = appRouter.createCaller(ctx); + + const project = await caller.project.createProject({ + name: 'Character System Test Project', }); 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, + const character = await caller.project.createCharacter({ + 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, }); expect(character).toMatchObject({ @@ -47,12 +50,9 @@ describe('tRPC API Layer - Character System', () => { }); it('should default role to supporting when not provided', async () => { - const character = await appRouter.project.createCharacter.mutate({ - input: { - name: 'Jane Smith', - projectId, - }, - ctx, + const character = await caller.project.createCharacter({ + name: 'Jane Smith', + projectId, }); expect(character.role).toBe('supporting'); @@ -61,19 +61,16 @@ describe('tRPC API Layer - Character System', () => { describe('updateCharacter', () => { it('should update character profile fields', async () => { - const created = await appRouter.project.createCharacter.mutate({ - input: { name: 'Original', projectId }, - ctx, + const created = await caller.project.createCharacter({ + name: 'Original', + projectId, }); - const updated = await appRouter.project.updateCharacter.mutate({ - input: { - id: created.id, - name: 'Updated Name', - bio: 'New bio', - role: 'antagonist', - }, - ctx, + const updated = await caller.project.updateCharacter({ + id: created.id, + name: 'Updated Name', + bio: 'New bio', + role: 'antagonist', }); expect(updated.name).toBe('Updated Name'); @@ -85,18 +82,20 @@ describe('tRPC API Layer - Character System', () => { describe('searchCharacters', () => { it('should filter characters by query', async () => { - await appRouter.project.createCharacter.mutate({ - input: { name: 'Alice', bio: 'The hero', projectId }, - ctx, + await caller.project.createCharacter({ + name: 'Alice', + bio: 'The hero', + projectId, }); - await appRouter.project.createCharacter.mutate({ - input: { name: 'Bob', bio: 'The villain', projectId }, - ctx, + await caller.project.createCharacter({ + name: 'Bob', + bio: 'The villain', + projectId, }); - const results = await appRouter.project.searchCharacters.query({ - input: { projectId, query: 'hero' }, - ctx, + const results = await caller.project.searchCharacters({ + projectId, + query: 'hero', }); expect(results.length).toBe(1); @@ -104,18 +103,20 @@ describe('tRPC API Layer - Character System', () => { }); it('should filter characters by role', async () => { - await appRouter.project.createCharacter.mutate({ - input: { name: 'Protag', role: 'protagonist', projectId }, - ctx, + await caller.project.createCharacter({ + name: 'Protag', + role: 'protagonist', + projectId, }); - await appRouter.project.createCharacter.mutate({ - input: { name: 'Antag', role: 'antagonist', projectId }, - ctx, + await caller.project.createCharacter({ + name: 'Antag', + role: 'antagonist', + projectId, }); - const results = await appRouter.project.searchCharacters.query({ - input: { projectId, role: 'protagonist' }, - ctx, + const results = await caller.project.searchCharacters({ + projectId, + role: 'protagonist', }); expect(results.length).toBe(1); @@ -125,24 +126,21 @@ describe('tRPC API Layer - Character System', () => { 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 charA = await caller.project.createCharacter({ + name: 'Character A', + projectId, }); - const charB = await appRouter.project.createCharacter.mutate({ - input: { name: 'Character B', projectId }, - ctx, + const charB = await caller.project.createCharacter({ + name: 'Character B', + projectId, }); - const rel = await appRouter.project.createRelationship.mutate({ - input: { - characterIdA: charA.id, - characterIdB: charB.id, - relationshipType: 'friendship', - strength: 80, - isAntagonistic: false, - }, - ctx, + const rel = await caller.project.createRelationship({ + characterIdA: charA.id, + characterIdB: charB.id, + relationshipType: 'friendship', + strength: 80, + isAntagonistic: false, }); expect(rel.characterIdA).toBe(charA.id); @@ -152,50 +150,41 @@ describe('tRPC API Layer - Character System', () => { }); it('should prevent self-relationships', async () => { - const charA = await appRouter.project.createCharacter.mutate({ - input: { name: 'Character A', projectId }, - ctx, + const charA = await caller.project.createCharacter({ + name: 'Character A', + projectId, }); await expect( - appRouter.project.createRelationship.mutate({ - input: { - characterIdA: charA.id, - characterIdB: charA.id, - relationshipType: 'friendship', - }, - ctx, + caller.project.createRelationship({ + characterIdA: charA.id, + characterIdB: charA.id, + relationshipType: 'friendship', }) ).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 charA = await caller.project.createCharacter({ + name: 'Character A', + projectId, }); - const charB = await appRouter.project.createCharacter.mutate({ - input: { name: 'Character B', projectId }, - ctx, + const charB = await caller.project.createCharacter({ + name: 'Character B', + projectId, }); - await appRouter.project.createRelationship.mutate({ - input: { - characterIdA: charA.id, - characterIdB: charB.id, - relationshipType: 'friendship', - }, - ctx, + await caller.project.createRelationship({ + characterIdA: charA.id, + characterIdB: charB.id, + relationshipType: 'friendship', }); await expect( - appRouter.project.createRelationship.mutate({ - input: { - characterIdA: charA.id, - characterIdB: charB.id, - relationshipType: 'rivalry', - }, - ctx, + caller.project.createRelationship({ + characterIdA: charA.id, + characterIdB: charB.id, + relationshipType: 'rivalry', }) ).rejects.toThrow('Relationship already exists between these characters'); }); @@ -203,32 +192,25 @@ describe('tRPC API Layer - Character System', () => { 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 charA = await caller.project.createCharacter({ + name: 'Character A', + projectId, }); - const charB = await appRouter.project.createCharacter.mutate({ - input: { name: 'Character B', projectId }, - ctx, + const charB = await caller.project.createCharacter({ + name: 'Character B', + projectId, }); - await appRouter.project.createRelationship.mutate({ - input: { - characterIdA: charA.id, - characterIdB: charB.id, - relationshipType: 'friendship', - }, - ctx, + await caller.project.createRelationship({ + characterIdA: charA.id, + characterIdB: charB.id, + relationshipType: 'friendship', }); - await appRouter.project.deleteCharacter.mutate({ - input: { id: charA.id }, - ctx, - }); + await caller.project.deleteCharacter({ id: charA.id }); - const rels = await appRouter.project.getRelationshipsForCharacter.query({ - input: { characterId: charB.id }, - ctx, + const rels = await caller.project.getRelationshipsForCharacter({ + characterId: charB.id, }); expect(rels.length).toBe(0); @@ -237,14 +219,13 @@ describe('tRPC API Layer - Character System', () => { describe('getCharacterStats', () => { it('should return stats for a character', async () => { - const charA = await appRouter.project.createCharacter.mutate({ - input: { name: 'TestChar', projectId }, - ctx, + const charA = await caller.project.createCharacter({ + name: 'TestChar', + projectId, }); - const stats = await appRouter.project.getCharacterStats.query({ - input: { characterId: charA.id }, - ctx, + const stats = await caller.project.getCharacterStats({ + characterId: charA.id, }); expect(stats.characterId).toBe(charA.id); diff --git a/server/trpc/index.ts b/server/trpc/index.ts index 195fe871e..65d6f575f 100644 --- a/server/trpc/index.ts +++ b/server/trpc/index.ts @@ -1,4 +1,4 @@ -import { initHTTPServer } from '@trpc/server/adapters/node-http'; +import { createHTTPServer } from '@trpc/server/adapters/standalone'; import { projectRouter } from './project-router'; import { revisionsRouter } from './revisions-router'; import type { TRPCContext } from './types'; @@ -15,22 +15,15 @@ export type AppRouter = typeof appRouter; // Create tRPC HTTP server export function createTRPCServer(port: number = 8080) { - const server = initHTTPServer({ + const server = createHTTPServer({ 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 - + createContext: async (): Promise => { return { - userId, + userId: undefined, }; }, - onError: ({ error, path, input }: { error: TRPCError; path: string; input: unknown }) => { - console.error(`tRPC error on ${path}:`, { - input, - error: error.message, - }); + onError: ({ error, path }: { error: TRPCError; path: string | undefined }) => { + console.error(`tRPC error on ${path}:`, error.message); }, }); diff --git a/server/trpc/project-router.test.ts b/server/trpc/project-router.test.ts index 77f92954b..2150cec10 100644 --- a/server/trpc/project-router.test.ts +++ b/server/trpc/project-router.test.ts @@ -1,71 +1,63 @@ import { describe, it, expect, beforeEach } from 'vitest'; import { appRouter } from './index'; -import type { Project } from '../../server/types/project'; +import { getTestDb, resetTestDb } from './test-setup'; +import type { TRPCContext } from './types'; describe('tRPC API Layer', () => { - let ctx: { userId: string }; + let ctx: TRPCContext; + let caller: ReturnType; + let projectId: number; - beforeEach(() => { - ctx = { userId: '123e4567-e89b-12d3-a456-426614174000' }; + beforeEach(async () => { + await resetTestDb(); + const db = await getTestDb(); + ctx = { userId: 1, db }; + caller = appRouter.createCaller(ctx); }); 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, + const project = await caller.project.createProject({ + name: 'Test Project', + description: 'A test project', }); expect(project).toMatchObject({ name: 'Test Project', description: 'A test project', - userId: ctx.userId, + ownerId: ctx.userId, }); expect(project.id).toBeDefined(); + expect(project.id).toBeGreaterThan(0); 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 }, - }); + await caller.project.createProject({ name: 'Test Project' }); + + const projects = await caller.project.listProjects(); expect(Array.isArray(projects)).toBe(true); + expect(projects.length).toBeGreaterThan(0); }); it('should get a specific project', async () => { - // First create a project - const created = await appRouter.project.createProject.mutate({ - input: { name: 'Get Test' }, - ctx, - }); + const created = await caller.project.createProject({ name: 'Get Test' }); - const project = await appRouter.project.getProject.query({ - input: { id: created.id }, - ctx, - }); + const project = await caller.project.getProject({ id: created.id }); 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 created = await caller.project.createProject({ name: 'Update Test' }); - const updated = await appRouter.project.updateProject.mutate({ - input: { - id: created.id, - name: 'Updated Test', - description: 'Updated description', - }, - ctx, + const updated = await caller.project.updateProject({ + id: created.id, + name: 'Updated Test', + description: 'Updated description', }); expect(updated.name).toBe('Updated Test'); @@ -73,39 +65,27 @@ describe('tRPC API Layer', () => { }); it('should delete a project', async () => { - const created = await appRouter.project.createProject.mutate({ - input: { name: 'Delete Test' }, - ctx, - }); + const created = await caller.project.createProject({ name: 'Delete Test' }); - const result = await appRouter.project.deleteProject.mutate({ - input: { id: created.id }, - ctx, - }); + const result = await caller.project.deleteProject({ id: created.id }); 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, + const project = await caller.project.createProject({ + name: 'Character Test Project', }); 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, + const character = await caller.project.createCharacter({ + name: 'John Doe', + description: 'Main character', + projectId, }); expect(character).toMatchObject({ @@ -116,40 +96,28 @@ describe('tRPC API Layer', () => { }); it('should list characters for a project', async () => { - await appRouter.project.createCharacter.mutate({ - input: { name: 'Char 1', projectId }, - ctx, - }); + await caller.project.createCharacter({ name: 'Char 1', projectId }); - const characters = await appRouter.project.listCharacters.query({ - input: { projectId }, - ctx, - }); + const characters = await caller.project.listCharacters({ projectId }); 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, + const project = await caller.project.createProject({ + name: 'Scene Test Project', }); 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, + const scene = await caller.project.createScene({ + title: 'INT. OFFICE - DAY', + content: 'John sits at his desk.', + projectId, + order: 1, }); expect(scene).toMatchObject({ @@ -161,29 +129,21 @@ describe('tRPC API Layer', () => { }); it('should list scenes for a project', async () => { - await appRouter.project.createScene.mutate({ - input: { title: 'Scene 1', projectId, order: 1 }, - ctx, - }); + await caller.project.createScene({ title: 'Scene 1', projectId, order: 1 }); - const scenes = await appRouter.project.listScenes.query({ - input: { projectId }, - ctx, - }); + const scenes = await caller.project.listScenes({ projectId }); 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 scene = await caller.project.createScene({ + title: 'Reorder Scene', + projectId, + order: 1, }); - const updated = await appRouter.project.updateScene.mutate({ - input: { id: scene.id, order: 5 }, - ctx, - }); + const updated = await caller.project.updateScene({ id: scene.id, order: 5 }); expect(updated.order).toBe(5); }); @@ -192,20 +152,14 @@ describe('tRPC API Layer', () => { 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, - }) + caller.project.getProject({ id: 99999 }) ).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 }); + await expect( + caller.project.deleteProject({ id: 99999 }) + ).rejects.toThrow('not found'); }); }); }); diff --git a/server/trpc/project-router.ts b/server/trpc/project-router.ts index 8f63131cc..bf98de51f 100644 --- a/server/trpc/project-router.ts +++ b/server/trpc/project-router.ts @@ -172,12 +172,15 @@ export const projectRouter = { }), // Character CRUD procedures - listCharacters: projectProcedure.query(async ({ ctx }) => { - return await ctx.db!.select() - .from(characters) - .where(eq(characters.projectId, ctx.projectId!)) - .orderBy(characters.name); - }), + listCharacters: protectedProcedure + .input(z.object({ projectId: z.number().int().positive() })) + .query(async ({ input, ctx }) => { + await verifyProjectOwnership(ctx.db!, input.projectId, ctx.userId!); + return await ctx.db!.select() + .from(characters) + .where(eq(characters.projectId, input.projectId)) + .orderBy(characters.name); + }), getCharacter: protectedProcedure .input(z.object({ id: z.number().int().positive() })) @@ -529,12 +532,15 @@ export const projectRouter = { }), // Scene procedures - listScenes: projectProcedure.query(async ({ ctx }) => { - return await ctx.db!.select() - .from(scenes) - .where(eq(scenes.projectId, ctx.projectId!)) - .orderBy(scenes.order); - }), + listScenes: protectedProcedure + .input(z.object({ projectId: z.number().int().positive() })) + .query(async ({ input, ctx }) => { + await verifyProjectOwnership(ctx.db!, input.projectId, ctx.userId!); + return await ctx.db!.select() + .from(scenes) + .where(eq(scenes.projectId, input.projectId)) + .orderBy(scenes.order); + }), getScene: protectedProcedure .input(z.object({ id: z.number().int().positive() })) diff --git a/server/trpc/router.ts b/server/trpc/router.ts index ccb2a8d5e..9376c1685 100644 --- a/server/trpc/router.ts +++ b/server/trpc/router.ts @@ -13,22 +13,31 @@ const isAuthenticated = t.middleware(({ ctx, next }) => { 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' }); +// Middleware for database access +const hasDb = t.middleware(({ ctx, next }) => { + if (!ctx.db) { + throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Database not available' }); } - return next({ ctx: { ...ctx, projectId } }); + return next({ ctx: { ...ctx, db: ctx.db } }); }); // 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); +export const publicProcedure = t.procedure.use(hasDb); +export const protectedProcedure = t.procedure.use(isAuthenticated).use(hasDb); +const hasProjectAccess = t.middleware(({ ctx, next }) => { + if (!ctx.projectId) { + throw new TRPCError({ code: 'FORBIDDEN', message: 'Project access required' }); + } + return next({ ctx: { ...ctx, projectId: ctx.projectId } }); +}); + +export const projectProcedure = t.procedure + .use(isAuthenticated) + .use(hasDb) + .use(hasProjectAccess); // Validation middleware export const validateInput = (schema: T) => { diff --git a/server/trpc/test-setup.ts b/server/trpc/test-setup.ts new file mode 100644 index 000000000..f931637b0 --- /dev/null +++ b/server/trpc/test-setup.ts @@ -0,0 +1,133 @@ +import { drizzle } from 'drizzle-orm/better-sqlite3'; +import Database from 'better-sqlite3'; + +let testDb: ReturnType | null = null; +let sqlite: Database.Database | null = null; + +const schemaSQL = ` + CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + email TEXT NOT NULL UNIQUE, + name TEXT, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + + CREATE TABLE IF NOT EXISTS projects ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + description TEXT, + owner_id INTEGER NOT NULL REFERENCES users(id), + is_public INTEGER NOT NULL DEFAULT 0, + theme TEXT, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + + CREATE TABLE IF NOT EXISTS characters ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + project_id INTEGER NOT NULL REFERENCES projects(id), + name TEXT NOT NULL, + slug TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'supporting', + bio TEXT, + description TEXT, + arc TEXT, + arc_type TEXT, + age INTEGER, + gender TEXT, + voice TEXT, + traits TEXT, + motivation TEXT, + conflict TEXT, + secret TEXT, + image_url TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ); + + CREATE TABLE IF NOT EXISTS character_relationships ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + character_a_id INTEGER NOT NULL REFERENCES characters(id), + character_b_id INTEGER NOT NULL REFERENCES characters(id), + relationship_type TEXT NOT NULL, + description TEXT, + strength INTEGER NOT NULL DEFAULT 50, + is_antagonistic INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ); + + CREATE TABLE IF NOT EXISTS scenes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + project_id INTEGER NOT NULL REFERENCES projects(id), + title TEXT NOT NULL, + content TEXT NOT NULL DEFAULT '', + "order" INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ); + + CREATE TABLE IF NOT EXISTS scene_characters ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + scene_id INTEGER NOT NULL REFERENCES scenes(id), + character_id INTEGER NOT NULL REFERENCES characters(id), + screen_time INTEGER, + dialogue_lines INTEGER DEFAULT 0 + ); + + CREATE TABLE IF NOT EXISTS scripts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + project_id INTEGER NOT NULL REFERENCES projects(id), + title TEXT NOT NULL, + version TEXT NOT NULL DEFAULT '1.0', + content TEXT, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + + CREATE TABLE IF NOT EXISTS revisions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + script_id INTEGER NOT NULL REFERENCES scripts(id), + title TEXT NOT NULL, + description TEXT, + version TEXT NOT NULL, + content TEXT, + created_by INTEGER NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + + CREATE TABLE IF NOT EXISTS revision_changes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + revision_id INTEGER NOT NULL REFERENCES revisions(id), + change_type TEXT NOT NULL, + description TEXT, + scene_number INTEGER, + line_number INTEGER, + old_content TEXT, + new_content TEXT, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + `; + +export async function getTestDb(): Promise> { + if (testDb && sqlite) return testDb; + + sqlite = new Database(':memory:'); + sqlite.exec('PRAGMA foreign_keys = OFF;'); + sqlite.exec(schemaSQL); + sqlite.exec('PRAGMA foreign_keys = ON;'); + + // Insert a test user + sqlite.exec("INSERT INTO users (id, email, name) VALUES (1, 'test@test.com', 'Test User');"); + + testDb = drizzle(sqlite); + + return testDb; +} + +export async function resetTestDb(): Promise> { + testDb = null; + sqlite = null; + return getTestDb(); +} diff --git a/server/trpc/types.ts b/server/trpc/types.ts index cede72b7a..772d2645d 100644 --- a/server/trpc/types.ts +++ b/server/trpc/types.ts @@ -1,43 +1,44 @@ import { z } from 'zod'; -// Base types +// Base types - IDs are integers matching Drizzle schema export const ProjectSchema = z.object({ - id: z.string().uuid(), + id: z.number().int().positive(), name: z.string().min(1).max(255), - description: z.string().optional(), - userId: z.string().uuid(), + description: z.string().nullable(), + userId: z.number().int().positive(), + isPublic: z.boolean(), createdAt: z.date(), updatedAt: z.date(), }); export const CharacterSchema = z.object({ - id: z.string().uuid(), + id: z.number().int().positive(), 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(), + description: z.string().nullable(), + bio: z.string().nullable(), + role: z.enum(['protagonist', 'antagonist', 'supporting', 'background', 'ensemble']), + arc: z.string().nullable(), + arcType: z.enum(['positive', 'negative', 'flat', 'complex']).nullable(), + age: z.number().int().nullable(), + gender: z.string().nullable(), + voice: z.string().nullable(), + traits: z.string().nullable(), + motivation: z.string().nullable(), + conflict: z.string().nullable(), + secret: z.string().nullable(), + imageUrl: z.string().nullable(), + projectId: z.number().int().positive(), createdAt: z.date(), updatedAt: z.date(), }); export const CharacterRelationshipSchema = z.object({ - id: z.string().uuid(), - characterIdA: z.string().uuid(), - characterIdB: z.string().uuid(), + id: z.number().int().positive(), + 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(), + description: z.string().nullable(), strength: z.number().int().min(0).max(100), isAntagonistic: z.boolean(), createdAt: z.date(), @@ -45,7 +46,7 @@ export const CharacterRelationshipSchema = z.object({ }); export const CharacterStatsSchema = z.object({ - characterId: z.string().uuid(), + characterId: z.number().int().positive(), totalScreenTime: z.number().int(), totalDialogueLines: z.number().int(), sceneCount: z.number().int(), @@ -53,24 +54,15 @@ export const CharacterStatsSchema = z.object({ }); export const SceneSchema = z.object({ - id: z.string().uuid(), + id: z.number().int().positive(), title: z.string().min(1), content: z.string(), - projectId: z.string().uuid(), + projectId: z.number().int().positive(), 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), @@ -78,7 +70,7 @@ export const CreateProjectInputSchema = z.object({ }); export const UpdateProjectInputSchema = z.object({ - id: z.string().uuid(), + id: z.number().int().positive(), name: z.string().min(1).max(255).optional(), description: z.string().optional(), }); @@ -98,11 +90,11 @@ export const CreateCharacterInputSchema = z.object({ conflict: z.string().optional(), secret: z.string().optional(), imageUrl: z.string().url().optional(), - projectId: z.string().uuid(), + projectId: z.number().int().positive(), }); export const UpdateCharacterInputSchema = z.object({ - id: z.string().uuid(), + id: z.number().int().positive(), name: z.string().min(1).max(100).optional(), description: z.string().optional(), bio: z.string().optional(), @@ -117,12 +109,12 @@ export const UpdateCharacterInputSchema = z.object({ conflict: z.string().optional(), secret: z.string().optional(), imageUrl: z.string().url().optional(), - projectId: z.string().uuid().optional(), + projectId: z.number().int().positive().optional(), }); export const CreateRelationshipInputSchema = z.object({ - characterIdA: z.string().uuid(), - characterIdB: z.string().uuid(), + 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(), @@ -130,7 +122,7 @@ export const CreateRelationshipInputSchema = z.object({ }); export const UpdateRelationshipInputSchema = z.object({ - id: z.string().uuid(), + 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(), @@ -138,7 +130,7 @@ export const UpdateRelationshipInputSchema = z.object({ }); export const SearchCharactersInputSchema = z.object({ - projectId: z.string().uuid(), + 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(), @@ -147,12 +139,12 @@ export const SearchCharactersInputSchema = z.object({ export const CreateSceneInputSchema = z.object({ title: z.string().min(1), content: z.string().optional(), - projectId: z.string().uuid(), + projectId: z.number().int().positive(), order: z.number().int().nonnegative(), }); export const UpdateSceneInputSchema = z.object({ - id: z.string().uuid(), + id: z.number().int().positive(), title: z.string().min(1).optional(), content: z.string().optional(), order: z.number().int().nonnegative().optional(), @@ -167,6 +159,7 @@ export const SceneListSchema = z.array(SceneSchema); // Auth context export interface TRPCContext { - userId?: string; - projectId?: string; + userId?: number; + projectId?: number; + db?: import('../../src/db/config/migrations').DrizzleDB; } diff --git a/server/types/project.ts b/server/types/project.ts index d1e85c57e..581416df0 100644 --- a/server/types/project.ts +++ b/server/types/project.ts @@ -1,35 +1,20 @@ -import type { z } from 'zod'; -import { - ProjectSchema, - CharacterSchema, - CharacterRelationshipSchema, - CharacterStatsSchema, - SceneSchema, - ScriptVersionSchema, - CreateProjectInputSchema, - UpdateProjectInputSchema, - CreateCharacterInputSchema, - UpdateCharacterInputSchema, - CreateRelationshipInputSchema, - UpdateRelationshipInputSchema, - SearchCharactersInputSchema, - CreateSceneInputSchema, - UpdateSceneInputSchema, -} from '../trpc/types'; +import type { + Project as DrizzleProject, + Character as DrizzleCharacter, + CharacterRelationship as DrizzleCharacterRelationship, + Scene as DrizzleScene, +} from '../../src/db/schema'; -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; +// Re-export Drizzle types as the canonical types +export type Project = DrizzleProject; +export type Character = DrizzleCharacter; +export type CharacterRelationship = DrizzleCharacterRelationship; +export type Scene = DrizzleScene; -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; +export interface CharacterStats { + characterId: number; + totalScreenTime: number; + totalDialogueLines: number; + sceneCount: number; + relationshipCount: number; +} diff --git a/vite.config.ts b/vite.config.ts index 6f38b3340..f2c20d083 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -30,8 +30,4 @@ export default defineConfig({ }, }, }, - test: { - globals: true, - environment: 'node', - }, }); diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 000000000..e91ec9c22 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,17 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + globals: true, + environment: 'node', + deps: { + interopDefault: true, + }, + }, + optimizeDeps: { + include: ['ws'], + }, + ssr: { + noExternal: ['ws'], + }, +});