import { publicProcedure, protectedProcedure, projectProcedure, TRPCError } from './router'; import { z } from 'zod'; import { eq, and, or, like, sql, inArray, asc } from 'drizzle-orm'; import type { DrizzleDB } from '../../src/db/config/migrations'; import { projects, characters, characterRelationships, scenes, sceneCharacters, projectMembers, } from '../../src/db/schema'; function slugify(name: string): string { return name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, ''); } async function getCharacterStatsImpl( db: DrizzleDB, characterId: number ) { const characterRow = await db.select() .from(characters) .where(eq(characters.id, characterId)) .then(rows => rows[0]); if (!characterRow) return null; const sceneCharRows = await db.select() .from(sceneCharacters) .where(eq(sceneCharacters.characterId, characterId)); const relRows = await db.select() .from(characterRelationships) .where( or( eq(characterRelationships.characterIdA, characterId), eq(characterRelationships.characterIdB, characterId) ) ); const sceneCount = sceneCharRows.length; const totalDialogueLines = sceneCharRows.reduce( (sum, sc) => sum + (sc.dialogueLines || 0), 0 ); const totalScreenTime = sceneCharRows.reduce( (sum, sc) => sum + (sc.screenTime || 0), 0 ); return { characterId, totalScreenTime, totalDialogueLines, sceneCount, relationshipCount: relRows.length, }; } async function verifyProjectOwnership( db: DrizzleDB, projectId: number, userId: number ) { const projectRows = await db.select({ id: projects.id, ownerId: projects.ownerId }) .from(projects) .where(eq(projects.id, projectId)); const project = projectRows[0]; if (!project) { throw new TRPCError({ code: 'NOT_FOUND', message: `Project ${projectId} not found` }); } if (project.ownerId !== userId) { throw new TRPCError({ code: 'FORBIDDEN', message: `You do not have access to project ${projectId}` }); } return project; } async function verifyProjectAccess( db: DrizzleDB, projectId: number, userId: number ) { const projectRows = await db.select({ id: projects.id, ownerId: projects.ownerId }) .from(projects) .where(eq(projects.id, projectId)); const project = projectRows[0]; if (!project) { throw new TRPCError({ code: 'NOT_FOUND', message: `Project ${projectId} not found` }); } if (project.ownerId === userId) return project; const memberRows = await db.select() .from(projectMembers) .where(and(eq(projectMembers.projectId, projectId), eq(projectMembers.userId, userId))); if (memberRows.length === 0) { throw new TRPCError({ code: 'FORBIDDEN', message: `You do not have access to project ${projectId}` }); } return project; } async function verifyProjectRole( db: DrizzleDB, projectId: number, userId: number, allowedRoles: string[] ) { await verifyProjectAccess(db, projectId, userId); const projectRows = await db.select({ id: projects.id, ownerId: projects.ownerId }) .from(projects) .where(eq(projects.id, projectId)); const project = projectRows[0]; if (!project) return; if (project.ownerId === userId) return; const memberRows = await db.select() .from(projectMembers) .where(and(eq(projectMembers.projectId, projectId), eq(projectMembers.userId, userId))); const member = memberRows[0]; if (!member || !allowedRoles.includes(member.role)) { throw new TRPCError({ code: 'FORBIDDEN', message: 'Insufficient permissions' }); } } export const projectRouter = { // Project procedures listProjects: protectedProcedure.query(async ({ ctx }) => { const owned = await ctx.db!.select() .from(projects) .where(eq(projects.ownerId, ctx.userId!)) .orderBy(asc(projects.updatedAt)); const memberRows = await ctx.db!.select({ projectId: projectMembers.projectId }) .from(projectMembers) .where(eq(projectMembers.userId, ctx.userId!)); const memberProjectIds = new Set(memberRows.map((r) => r.projectId)); const memberProjects: typeof owned = []; for (const pid of memberProjectIds) { const row = await ctx.db!.select() .from(projects) .where(eq(projects.id, pid)) .then((r) => r[0]); if (row) memberProjects.push(row); } const all = [...owned, ...memberProjects]; const seen = new Set(all.map((p) => p.id)); return all.filter((p) => seen.has(p.id)); }), getProject: protectedProcedure .input(z.object({ id: z.number().int().positive() })) .query(async ({ input, ctx }) => { const rows = await ctx.db!.select() .from(projects) .where(eq(projects.id, input.id)); const project = rows[0]; if (!project) { throw new TRPCError({ code: 'NOT_FOUND', message: `Project ${input.id} not found` }); } if (project.ownerId === ctx.userId || project.isPublic) return project; const memberRows = await ctx.db!.select() .from(projectMembers) .where(and(eq(projectMembers.projectId, input.id), eq(projectMembers.userId, ctx.userId!))); if (memberRows.length === 0) { throw new TRPCError({ code: 'FORBIDDEN', message: `You do not have access to project ${input.id}` }); } return project; }), createProject: protectedProcedure .input(z.object({ name: z.string().min(1).max(255), description: z.string().optional(), })) .mutation(async ({ input, ctx }) => { const result = await ctx.db!.insert(projects) .values({ name: input.name, description: input.description ?? null, ownerId: ctx.userId!, }) .returning(); return result[0]; }), updateProject: protectedProcedure .input(z.object({ id: z.number().int().positive(), name: z.string().min(1).max(255).optional(), description: z.string().optional(), })) .mutation(async ({ input, ctx }) => { await verifyProjectOwnership(ctx.db!, input.id, ctx.userId!); const updateData: Record = { updatedAt: new Date() }; if (input.name !== undefined) updateData.name = input.name; if (input.description !== undefined) updateData.description = input.description ?? null; const result = await ctx.db!.update(projects) .set(updateData) .where(eq(projects.id, input.id)) .returning(); return result[0]; }), deleteProject: protectedProcedure .input(z.object({ id: z.number().int().positive() })) .mutation(async ({ input, ctx }) => { await verifyProjectOwnership(ctx.db!, input.id, ctx.userId!); // Cascade delete: remove scenes first await ctx.db!.delete(scenes) .where(eq(scenes.projectId, input.id)); // Get character IDs for this project const projectCharacters = await ctx.db!.select({ id: characters.id }) .from(characters) .where(eq(characters.projectId, input.id)); // Delete relationships for each character for (const char of projectCharacters) { await ctx.db!.delete(characterRelationships) .where( or( eq(characterRelationships.characterIdA, char.id), eq(characterRelationships.characterIdB, char.id) ) ); } // Delete characters await ctx.db!.delete(characters) .where(eq(characters.projectId, input.id)); // Delete project const result = await ctx.db!.delete(projects) .where(eq(projects.id, input.id)); return { success: true }; }), // Character CRUD procedures listCharacters: 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() })) .query(async ({ input, ctx }) => { const rows = await ctx.db!.select() .from(characters) .where(eq(characters.id, input.id)); const character = rows[0]; if (!character) { throw new TRPCError({ code: 'NOT_FOUND', message: `Character ${input.id} not found` }); } await verifyProjectOwnership(ctx.db!, character.projectId, ctx.userId!); return character; }), createCharacter: protectedProcedure .input(z.object({ name: z.string().min(1).max(100), description: z.string().optional(), bio: z.string().optional(), role: z.enum(['protagonist', 'antagonist', 'supporting', 'background', 'ensemble']).optional(), arc: z.string().optional(), arcType: z.enum(['positive', 'negative', 'flat', 'complex']).optional(), age: z.number().int().optional(), gender: z.string().optional(), voice: z.string().optional(), traits: z.string().optional(), motivation: z.string().optional(), conflict: z.string().optional(), secret: z.string().optional(), imageUrl: z.string().url().optional(), projectId: z.number().int().positive(), })) .mutation(async ({ input, ctx }) => { await verifyProjectOwnership(ctx.db!, input.projectId, ctx.userId!); const result = await ctx.db!.insert(characters) .values({ name: input.name, slug: slugify(input.name), description: input.description ?? null, bio: input.bio ?? null, role: input.role ?? 'supporting', arc: input.arc ?? null, arcType: input.arcType ?? null, age: input.age ?? null, gender: input.gender ?? null, voice: input.voice ?? null, traits: input.traits ?? null, motivation: input.motivation ?? null, conflict: input.conflict ?? null, secret: input.secret ?? null, imageUrl: input.imageUrl ?? null, projectId: input.projectId, }) .returning(); return result[0]; }), updateCharacter: protectedProcedure .input(z.object({ id: z.number().int().positive(), name: z.string().min(1).max(100).optional(), description: z.string().optional(), bio: z.string().optional(), role: z.enum(['protagonist', 'antagonist', 'supporting', 'background', 'ensemble']).optional(), arc: z.string().optional(), arcType: z.enum(['positive', 'negative', 'flat', 'complex']).optional(), age: z.number().int().optional(), gender: z.string().optional(), voice: z.string().optional(), traits: z.string().optional(), motivation: z.string().optional(), conflict: z.string().optional(), secret: z.string().optional(), imageUrl: z.string().url().optional(), projectId: z.number().int().positive().optional(), })) .mutation(async ({ input, ctx }) => { const existingRows = await ctx.db!.select() .from(characters) .where(eq(characters.id, input.id)); const existing = existingRows[0]; if (!existing) { throw new TRPCError({ code: 'NOT_FOUND', message: `Character ${input.id} not found` }); } await verifyProjectOwnership(ctx.db!, existing.projectId, ctx.userId!); const updateData: Record = { updatedAt: new Date() }; if (input.name !== undefined) { updateData.name = input.name; updateData.slug = slugify(input.name); } if (input.description !== undefined) updateData.description = input.description ?? null; if (input.bio !== undefined) updateData.bio = input.bio ?? null; if (input.role !== undefined) updateData.role = input.role; if (input.arc !== undefined) updateData.arc = input.arc ?? null; if (input.arcType !== undefined) updateData.arcType = input.arcType ?? null; if (input.age !== undefined) updateData.age = input.age ?? null; if (input.gender !== undefined) updateData.gender = input.gender ?? null; if (input.voice !== undefined) updateData.voice = input.voice ?? null; if (input.traits !== undefined) updateData.traits = input.traits ?? null; if (input.motivation !== undefined) updateData.motivation = input.motivation ?? null; if (input.conflict !== undefined) updateData.conflict = input.conflict ?? null; if (input.secret !== undefined) updateData.secret = input.secret ?? null; if (input.imageUrl !== undefined) updateData.imageUrl = input.imageUrl ?? null; if (input.projectId !== undefined) updateData.projectId = input.projectId; const result = await ctx.db!.update(characters) .set(updateData) .where(eq(characters.id, input.id)) .returning(); return result[0]; }), deleteCharacter: protectedProcedure .input(z.object({ id: z.number().int().positive() })) .mutation(async ({ input, ctx }) => { const existingRows = await ctx.db!.select() .from(characters) .where(eq(characters.id, input.id)); const existing = existingRows[0]; if (!existing) { throw new TRPCError({ code: 'NOT_FOUND', message: `Character ${input.id} not found` }); } await verifyProjectOwnership(ctx.db!, existing.projectId, ctx.userId!); // Remove associated relationships await ctx.db!.delete(characterRelationships) .where( or( eq(characterRelationships.characterIdA, input.id), eq(characterRelationships.characterIdB, input.id) ) ); await ctx.db!.delete(characters) .where(eq(characters.id, input.id)); return { success: true }; }), searchCharacters: protectedProcedure .input(z.object({ projectId: z.number().int().positive(), query: z.string().optional(), role: z.enum(['protagonist', 'antagonist', 'supporting', 'background', 'ensemble']).optional(), arcType: z.enum(['positive', 'negative', 'flat', 'complex']).optional(), })) .query(async ({ input, ctx }) => { await verifyProjectOwnership(ctx.db!, input.projectId, ctx.userId!); const conditions: import('drizzle-orm').SQL[] = [eq(characters.projectId, input.projectId)]; if (input.query) { const q = `%${input.query.toLowerCase()}%`; conditions.push( 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.push(eq(characters.role, input.role)); } if (input.arcType) { conditions.push(eq(characters.arcType, input.arcType)); } return await ctx.db!.select() .from(characters) .where(and(...conditions)) .orderBy(characters.name); }), getCharacterStats: protectedProcedure .input(z.object({ characterId: z.number().int().positive() })) .query(async ({ input, ctx }) => { const rows = await ctx.db!.select() .from(characters) .where(eq(characters.id, input.characterId)); if (!rows[0]) { throw new TRPCError({ code: 'NOT_FOUND', message: `Character ${input.characterId} not found` }); } await verifyProjectOwnership(ctx.db!, rows[0].projectId, ctx.userId!); return await getCharacterStatsImpl(ctx.db!, input.characterId); }), getProjectCharacterStats: projectProcedure .query(async ({ ctx }) => { const projectCharacters = await ctx.db!.select() .from(characters) .where(eq(characters.projectId, ctx.projectId!)); const stats = []; for (const c of projectCharacters) { const s = await getCharacterStatsImpl(ctx.db!, c.id); if (s) stats.push(s); } return stats; }), // Relationship procedures listRelationships: projectProcedure .query(async ({ ctx }) => { const projectCharacterIds = await ctx.db!.select({ id: characters.id }) .from(characters) .where(eq(characters.projectId, ctx.projectId!)); const idList = projectCharacterIds.map(c => c.id); if (idList.length === 0) return []; return await ctx.db!.select() .from(characterRelationships) .where( and( inArray(characterRelationships.characterIdA, idList), inArray(characterRelationships.characterIdB, idList) ) ); }), getRelationshipsForCharacter: protectedProcedure .input(z.object({ characterId: z.number().int().positive() })) .query(async ({ input, ctx }) => { const rows = await ctx.db!.select() .from(characters) .where(eq(characters.id, input.characterId)); if (!rows[0]) { throw new TRPCError({ code: 'NOT_FOUND', message: `Character ${input.characterId} not found` }); } await verifyProjectOwnership(ctx.db!, rows[0].projectId, ctx.userId!); return await ctx.db!.select() .from(characterRelationships) .where( or( eq(characterRelationships.characterIdA, input.characterId), eq(characterRelationships.characterIdB, input.characterId) ) ); }), createRelationship: protectedProcedure .input(z.object({ characterIdA: z.number().int().positive(), characterIdB: z.number().int().positive(), relationshipType: z.enum(['family', 'romantic', 'friendship', 'rivalry', 'mentor', 'alliance', 'conflict', 'professional', 'other']), description: z.string().optional(), strength: z.number().int().min(0).max(100).optional(), isAntagonistic: z.boolean().optional(), })) .mutation(async ({ input, ctx }) => { if (input.characterIdA === input.characterIdB) { throw new TRPCError({ code: 'BAD_REQUEST', message: 'Cannot create a relationship with the same character' }); } const charARows = await ctx.db!.select() .from(characters) .where(eq(characters.id, input.characterIdA)); const charBRows = await ctx.db!.select() .from(characters) .where(eq(characters.id, input.characterIdB)); if (!charARows[0] || !charBRows[0]) { throw new TRPCError({ code: 'NOT_FOUND', message: 'Both characters must exist' }); } await verifyProjectOwnership(ctx.db!, charARows[0].projectId, ctx.userId!); const existing = await ctx.db!.select() .from(characterRelationships) .where( or( and( eq(characterRelationships.characterIdA, input.characterIdA), eq(characterRelationships.characterIdB, input.characterIdB) ), and( eq(characterRelationships.characterIdA, input.characterIdB), eq(characterRelationships.characterIdB, input.characterIdA) ) ) ); if (existing.length > 0) { throw new Error('Relationship already exists between these characters'); } const result = await ctx.db!.insert(characterRelationships) .values({ characterIdA: input.characterIdA, characterIdB: input.characterIdB, relationshipType: input.relationshipType, description: input.description ?? null, strength: input.strength ?? 50, isAntagonistic: input.isAntagonistic ?? false, }) .returning(); return result[0]; }), updateRelationship: protectedProcedure .input(z.object({ id: z.number().int().positive(), relationshipType: z.enum(['family', 'romantic', 'friendship', 'rivalry', 'mentor', 'alliance', 'conflict', 'professional', 'other']).optional(), description: z.string().optional(), strength: z.number().int().min(0).max(100).optional(), isAntagonistic: z.boolean().optional(), })) .mutation(async ({ input, ctx }) => { const relRows = await ctx.db!.select() .from(characterRelationships) .where(eq(characterRelationships.id, input.id)); if (!relRows[0]) { throw new TRPCError({ code: 'NOT_FOUND', message: `Relationship ${input.id} not found` }); } const charARows = await ctx.db!.select() .from(characters) .where(eq(characters.id, relRows[0].characterIdA)); if (!charARows[0]) { throw new TRPCError({ code: 'NOT_FOUND', message: 'Character not found' }); } await verifyProjectOwnership(ctx.db!, charARows[0].projectId, ctx.userId!); const updateData: Record = { updatedAt: new Date() }; if (input.relationshipType !== undefined) updateData.relationshipType = input.relationshipType; if (input.description !== undefined) updateData.description = input.description ?? null; if (input.strength !== undefined) updateData.strength = input.strength; if (input.isAntagonistic !== undefined) updateData.isAntagonistic = input.isAntagonistic; const result = await ctx.db!.update(characterRelationships) .set(updateData) .where(eq(characterRelationships.id, input.id)) .returning(); return result[0]; }), deleteRelationship: protectedProcedure .input(z.object({ id: z.number().int().positive() })) .mutation(async ({ input, ctx }) => { const relRows = await ctx.db!.select() .from(characterRelationships) .where(eq(characterRelationships.id, input.id)); if (!relRows[0]) { throw new TRPCError({ code: 'NOT_FOUND', message: `Relationship ${input.id} not found` }); } const charARows = await ctx.db!.select() .from(characters) .where(eq(characters.id, relRows[0].characterIdA)); if (!charARows[0]) { throw new TRPCError({ code: 'NOT_FOUND', message: 'Character not found' }); } await verifyProjectOwnership(ctx.db!, charARows[0].projectId, ctx.userId!); await ctx.db!.delete(characterRelationships) .where(eq(characterRelationships.id, input.id)); return { success: true }; }), // Scene procedures 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() })) .query(async ({ input, ctx }) => { const rows = await ctx.db!.select() .from(scenes) .where(eq(scenes.id, input.id)); const scene = rows[0]; if (!scene) { throw new TRPCError({ code: 'NOT_FOUND', message: `Scene ${input.id} not found` }); } await verifyProjectOwnership(ctx.db!, scene.projectId, ctx.userId!); return scene; }), createScene: protectedProcedure .input(z.object({ title: z.string().min(1), content: z.string().optional(), projectId: z.number().int().positive(), order: z.number().int().nonnegative(), })) .mutation(async ({ input, ctx }) => { await verifyProjectOwnership(ctx.db!, input.projectId, ctx.userId!); const result = await ctx.db!.insert(scenes) .values({ title: input.title, content: input.content ?? '', projectId: input.projectId, order: input.order, }) .returning(); return result[0]; }), updateScene: protectedProcedure .input(z.object({ id: z.number().int().positive(), title: z.string().min(1).optional(), content: z.string().optional(), order: z.number().int().nonnegative().optional(), })) .mutation(async ({ input, ctx }) => { const rows = await ctx.db!.select() .from(scenes) .where(eq(scenes.id, input.id)); if (!rows[0]) { throw new TRPCError({ code: 'NOT_FOUND', message: `Scene ${input.id} not found` }); } const updateData: Record = { updatedAt: new Date() }; if (input.title !== undefined) updateData.title = input.title; if (input.content !== undefined) updateData.content = input.content ?? ''; if (input.order !== undefined) updateData.order = input.order; const result = await ctx.db!.update(scenes) .set(updateData) .where(eq(scenes.id, input.id)) .returning(); return result[0]; }), deleteScene: protectedProcedure .input(z.object({ id: z.number().int().positive() })) .mutation(async ({ input, ctx }) => { const rows = await ctx.db!.select() .from(scenes) .where(eq(scenes.id, input.id)); if (!rows[0]) { throw new TRPCError({ code: 'NOT_FOUND', message: `Scene ${input.id} not found` }); } // Check project ownership await verifyProjectOwnership(ctx.db!, rows[0].projectId, ctx.userId!); await ctx.db!.delete(scenes) .where(eq(scenes.id, input.id)); return { success: true }; }), // Project sharing and permissions listMembers: protectedProcedure .input(z.object({ projectId: z.number().int().positive() })) .query(async ({ input, ctx }) => { await verifyProjectAccess(ctx.db!, input.projectId, ctx.userId!); const members = await ctx.db!.select() .from(projectMembers) .where(eq(projectMembers.projectId, input.projectId)) .orderBy(asc(projectMembers.addedAt)); const projectRows = await ctx.db!.select() .from(projects) .where(eq(projects.id, input.projectId)); const project = projectRows[0]; if (!project) return members; return [ { userId: project.ownerId, role: 'owner' as const, projectId: input.projectId, addedAt: project.createdAt, id: -1 }, ...members, ]; }), shareProject: protectedProcedure .input(z.object({ projectId: z.number().int().positive(), userId: z.number().int().positive(), role: z.enum(['admin', 'editor', 'viewer']).default('editor'), })) .mutation(async ({ input, ctx }) => { await verifyProjectRole(ctx.db!, input.projectId, ctx.userId!, ['owner', 'admin']); if (input.userId === ctx.userId!) { throw new TRPCError({ code: 'BAD_REQUEST', message: 'You cannot share a project with yourself' }); } const existing = await ctx.db!.select() .from(projectMembers) .where(and(eq(projectMembers.projectId, input.projectId), eq(projectMembers.userId, input.userId))); if (existing.length > 0) { throw new TRPCError({ code: 'CONFLICT', message: 'User is already a member of this project' }); } const result = await ctx.db!.insert(projectMembers) .values({ projectId: input.projectId, userId: input.userId, role: input.role, }) .returning(); return result[0]; }), updateMemberRole: protectedProcedure .input(z.object({ projectId: z.number().int().positive(), userId: z.number().int().positive(), role: z.enum(['admin', 'editor', 'viewer']), })) .mutation(async ({ input, ctx }) => { await verifyProjectRole(ctx.db!, input.projectId, ctx.userId!, ['owner']); const result = await ctx.db!.update(projectMembers) .set({ role: input.role }) .where(and(eq(projectMembers.projectId, input.projectId), eq(projectMembers.userId, input.userId))) .returning(); if (result.length === 0) { throw new TRPCError({ code: 'NOT_FOUND', message: 'Member not found' }); } return result[0]; }), removeMember: protectedProcedure .input(z.object({ projectId: z.number().int().positive(), userId: z.number().int().positive(), })) .mutation(async ({ input, ctx }) => { await verifyProjectRole(ctx.db!, input.projectId, ctx.userId!, ['owner', 'admin']); if (input.userId === ctx.userId!) { throw new TRPCError({ code: 'FORBIDDEN', message: 'You cannot remove yourself from this project' }); } await ctx.db!.delete(projectMembers) .where(and(eq(projectMembers.projectId, input.projectId), eq(projectMembers.userId, input.userId))); return { success: true }; }), leaveProject: protectedProcedure .input(z.object({ projectId: z.number().int().positive() })) .mutation(async ({ input, ctx }) => { const projectRows = await ctx.db!.select() .from(projects) .where(eq(projects.id, input.projectId)); const project = projectRows[0]; if (!project) { throw new TRPCError({ code: 'NOT_FOUND', message: `Project ${input.projectId} not found` }); } if (project.ownerId === ctx.userId!) { throw new TRPCError({ code: 'FORBIDDEN', message: 'Owner cannot leave the project. Transfer ownership first.' }); } await ctx.db!.delete(projectMembers) .where(and(eq(projectMembers.projectId, input.projectId), eq(projectMembers.userId, ctx.userId!))); return { success: true }; }), };