Files
FrenoCorp/server/trpc/project-router.ts
Michael Freno 67c3881dcf Add waitlist schema for marketing (FRE-635)
- Created waitlist_signups and waitlist_events tables
- Supports email, name, source tracking, and status management
- Enables VIP supporter list for Product Hunt launch
- Migration 0002_chemical_shocker.sql generated
- Fixed brand color in product-hunt-assets-brief.md (#518ac8)
2026-04-26 06:21:20 -04:00

639 lines
22 KiB
TypeScript

import { publicProcedure, protectedProcedure, projectProcedure, TRPCError } from './router';
import { z } from 'zod';
import { eq, and, or, like, sql, inArray } from 'drizzle-orm';
import type { DrizzleDB } from '../../src/db/config/migrations';
import {
projects,
characters,
characterRelationships,
scenes,
sceneCharacters,
} from '../../src/db/schema';
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;
}
export const projectRouter = {
// Project procedures
listProjects: protectedProcedure.query(async ({ ctx }) => {
return await ctx.db!.select()
.from(projects)
.where(eq(projects.ownerId, ctx.userId!))
.orderBy(projects.updatedAt);
}),
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) {
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 };
}),
};