- Cleared cancelled blocker FRE-4428 - Updated to in_progress - Added status comment documenting delegated work to CTO/CMO Co-Authored-By: Paperclip <noreply@paperclip.ing>
828 lines
29 KiB
TypeScript
828 lines
29 KiB
TypeScript
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<string, any> = { 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<string, any> = { 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<string, any> = { 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<string, any> = { 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 };
|
|
}),
|
|
};
|