FRE-4534: Remove remaining scripter overlap items from FrenoCorp

All scripter code has been migrated to ~/code/scripter. This removes
the overlap items (src/, src-tauri/, server/trpc/, marketing/,
docs/, public/, dist/, index.html) with favor-newer policy applied.

All FrenoCorp-unique files archived to scripter/trpc/legacy/ for
reference. Scripter repo's modular tRPC structure supersedes the
flat-router format.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
2026-05-02 10:26:08 -04:00
parent 4e07718e69
commit 0cc005414d
373 changed files with 0 additions and 66538 deletions

View File

@@ -1,496 +0,0 @@
import { publicProcedure, protectedProcedure } from './router';
import { z } from 'zod';
import { eq, and, desc } from 'drizzle-orm';
import {
kpiSnapshots,
alertRules,
alerts,
scheduledReports,
npsResponses,
cohorts,
cohortMembers,
} from '../../src/db/schema';
import {
recordKPI,
getLatestKPI,
getKPIHistory,
getAllLatestKPIs,
checkKPIAgainstThreshold,
getKPIStatus,
KPI_THRESHOLDS,
type KPIKey,
} from '../../src/lib/analytics/kpi-service';
import {
evaluateAlertRules,
formatAlertMessage,
acknowledgeAlert,
getUnsentAlerts,
type SlackConfig,
} from '../../src/lib/analytics/slack-alerts';
import {
generateWeeklyReport,
generateMonthlyReport,
formatReportMarkdown,
formatReportSlackBlocks,
createScheduledReport,
getActiveScheduledReports,
runDueReports,
} from '../../src/lib/analytics/report-generator';
import {
createCohort,
addCohortMember,
getCohortAnalysis,
listCohorts,
getCohortSize,
createMonthlyCohortTemplate,
createWeeklyCohortTemplate,
createFeatureCohortTemplate,
} from '../../src/lib/analytics/cohort-analysis';
import {
submitNPSResponse,
calculateNPS,
getNPSResponses,
getNPSOverTime,
categorizeNPSScore,
generateNPSSurveyEmail,
generateNPSSurveyInAppPrompt,
type NPSScore,
} from '../../src/lib/analytics/nps-service';
const KPIKeySchema = z.enum([
'mau',
'paying_users',
'mrr',
'conversion_rate',
'churn_rate',
'cac',
'ltv',
'nps',
'viral_coefficient',
]);
const AlertConditionSchema = z.enum(['above', 'below', 'equals', 'increasing', 'decreasing']);
const AlertSeveritySchema = z.enum(['low', 'medium', 'high', 'critical']);
const ReportTypeSchema = z.enum(['weekly_kpi', 'monthly_kpi', 'cohort_analysis', 'nps_summary', 'custom']);
const ScheduleSchema = z.enum(['weekly', 'monthly', 'daily']);
const ReportFormatSchema = z.enum(['slack', 'email', 'both']);
export const analyticsRouter = {
// --- KPI Endpoints ---
getThresholds: publicProcedure.query(() => {
return KPI_THRESHOLDS;
}),
getLatestKPI: publicProcedure
.input(z.object({ kpiKey: KPIKeySchema }))
.query(async ({ input, ctx }) => {
return await getLatestKPI(ctx.db!, input.kpiKey as KPIKey);
}),
getAllLatestKPIs: protectedProcedure.query(async ({ ctx }) => {
return await getAllLatestKPIs(ctx.db!);
}),
getKPIHistory: publicProcedure
.input(z.object({
kpiKey: KPIKeySchema,
periodStart: z.string().datetime().optional(),
periodEnd: z.string().datetime().optional(),
}))
.query(async ({ input, ctx }) => {
return await getKPIHistory(
ctx.db!,
input.kpiKey as KPIKey,
input.periodStart ? new Date(input.periodStart) : undefined,
input.periodEnd ? new Date(input.periodEnd) : undefined,
);
}),
recordKPI: protectedProcedure
.input(z.object({
kpiKey: KPIKeySchema,
value: z.number(),
periodStart: z.string().datetime(),
periodEnd: z.string().datetime(),
metadata: z.record(z.string(), z.unknown()).optional(),
}))
.mutation(async ({ input, ctx }) => {
const snapshot = await recordKPI(
ctx.db!,
input.kpiKey as KPIKey,
input.value,
new Date(input.periodStart),
new Date(input.periodEnd),
input.metadata,
);
const results = await evaluateAlertRules(ctx.db!, input.kpiKey as KPIKey, input.value);
const triggeredAlerts = results.filter((r) => r.triggered);
return {
snapshot,
triggeredAlerts,
};
}),
checkThreshold: publicProcedure
.input(z.object({
kpiKey: KPIKeySchema,
value: z.number(),
}))
.query(async ({ input }) => {
const threshold = checkKPIAgainstThreshold(input.kpiKey as KPIKey, input.value);
const status = getKPIStatus(input.kpiKey as KPIKey, input.value);
return { ...threshold, status };
}),
// --- Alert Endpoints ---
getAlertRules: protectedProcedure.query(async ({ ctx }) => {
return await ctx.db!.select().from(alertRules).orderBy(desc(alertRules.createdAt));
}),
createAlertRule: protectedProcedure
.input(z.object({
name: z.string().min(1).max(200),
kpiKey: KPIKeySchema,
condition: AlertConditionSchema,
threshold: z.number(),
severity: AlertSeveritySchema,
channelId: z.string().max(100).optional(),
cooldownMinutes: z.number().int().min(1).max(1440).default(60),
}))
.mutation(async ({ input, ctx }) => {
const result = await ctx.db!.insert(alertRules).values({
name: input.name,
kpiKey: input.kpiKey,
condition: input.condition,
threshold: input.threshold,
severity: input.severity,
channelId: input.channelId ?? null,
isActive: true,
cooldownMinutes: input.cooldownMinutes,
createdBy: ctx.userId,
}).returning();
return result[0];
}),
updateAlertRule: protectedProcedure
.input(z.object({
id: z.number().int().positive(),
name: z.string().min(1).max(200).optional(),
condition: AlertConditionSchema.optional(),
threshold: z.number().optional(),
severity: AlertSeveritySchema.optional(),
channelId: z.string().max(100).nullable().optional(),
isActive: z.boolean().optional(),
cooldownMinutes: z.number().int().min(1).max(1440).optional(),
}))
.mutation(async ({ input, ctx }) => {
const { id, ...updates } = input;
const existing = await ctx.db!
.select({ id: alertRules.id, createdBy: alertRules.createdBy })
.from(alertRules)
.where(eq(alertRules.id, id))
.limit(1);
const rule = existing[0];
if (!rule) {
throw new (await import('./router')).TRPCError({ code: 'NOT_FOUND', message: 'Alert rule not found' });
}
if (rule.createdBy !== ctx.userId) {
throw new (await import('./router')).TRPCError({ code: 'FORBIDDEN', message: 'Not the rule owner' });
}
const result = await ctx.db!
.update(alertRules)
.set({ ...updates, updatedAt: new Date() })
.where(eq(alertRules.id, id))
.returning();
return result[0];
}),
deleteAlertRule: protectedProcedure
.input(z.object({ id: z.number().int().positive() }))
.mutation(async ({ input, ctx }) => {
const existing = await ctx.db!
.select({ id: alertRules.id, createdBy: alertRules.createdBy })
.from(alertRules)
.where(eq(alertRules.id, input.id))
.limit(1);
const rule = existing[0];
if (!rule) {
throw new (await import('./router')).TRPCError({ code: 'NOT_FOUND', message: 'Alert rule not found' });
}
if (rule.createdBy !== ctx.userId) {
throw new (await import('./router')).TRPCError({ code: 'FORBIDDEN', message: 'Not the rule owner' });
}
await ctx.db!.delete(alertRules).where(eq(alertRules.id, input.id));
return { success: true };
}),
getAlerts: protectedProcedure
.input(z.object({
severity: AlertSeveritySchema.optional(),
limit: z.number().int().min(1).max(200).default(50),
}))
.query(async ({ input, ctx }) => {
const conditions: import('drizzle-orm').SQL[] = [];
if (input.severity) {
conditions.push(eq(alerts.severity, input.severity));
}
const whereClause = conditions.length > 0 ? and(...conditions) : undefined;
const query = ctx.db!.select().from(alerts).orderBy(desc(alerts.createdAt)).limit(input.limit);
return whereClause ? await query.where(whereClause) : await query;
}),
acknowledgeAlert: protectedProcedure
.input(z.object({
alertId: z.number().int().positive(),
}))
.mutation(async ({ input, ctx }) => {
const alert = await acknowledgeAlert(ctx.db!, input.alertId, ctx.userId!);
return { success: !!alert, alert };
}),
getUnsentAlerts: publicProcedure.query(async ({ ctx }) => {
return await getUnsentAlerts(ctx.db!);
}),
// --- Report Endpoints ---
generateWeeklyReport: publicProcedure.query(async ({ ctx }) => {
return await generateWeeklyReport(ctx.db!);
}),
generateMonthlyReport: publicProcedure.query(async ({ ctx }) => {
return await generateMonthlyReport(ctx.db!);
}),
formatReportMarkdown: publicProcedure
.input(z.object({
periodStart: z.string().datetime(),
periodEnd: z.string().datetime(),
reportType: z.enum(['weekly', 'monthly']).default('weekly'),
}))
.query(async ({ input, ctx }) => {
const report = input.reportType === 'weekly'
? await generateWeeklyReport(ctx.db!)
: await generateMonthlyReport(ctx.db!);
return await formatReportMarkdown(report);
}),
formatReportSlackBlocks: publicProcedure
.input(z.object({
reportType: z.enum(['weekly', 'monthly']).default('weekly'),
}))
.query(async ({ input, ctx }) => {
const report = input.reportType === 'weekly'
? await generateWeeklyReport(ctx.db!)
: await generateMonthlyReport(ctx.db!);
return await formatReportSlackBlocks(report);
}),
getScheduledReports: publicProcedure.query(async ({ ctx }) => {
return await getActiveScheduledReports(ctx.db!);
}),
createScheduledReport: protectedProcedure
.input(z.object({
name: z.string().min(1).max(200),
reportType: ReportTypeSchema,
schedule: ScheduleSchema,
recipients: z.string().regex(/^[^\s@]+@[^\s@]+\.[^\s@]+(,[^\s@]+@[^\s@]+\.[^\s@]+)*$/, 'Each recipient must be a valid email'),
format: ReportFormatSchema,
metadata: z.record(z.string(), z.unknown()).optional(),
}))
.mutation(async ({ input, ctx }) => {
const report = await createScheduledReport(ctx.db!, {
name: input.name,
reportType: input.reportType,
schedule: input.schedule,
recipients: input.recipients,
format: input.format,
isActive: true,
metadata: input.metadata ? JSON.stringify(input.metadata) : null,
createdBy: ctx.userId,
});
return report;
}),
updateScheduledReport: protectedProcedure
.input(z.object({
id: z.number().int().positive(),
name: z.string().min(1).max(200).optional(),
reportType: ReportTypeSchema.optional(),
schedule: ScheduleSchema.optional(),
recipients: z.string().regex(/^[^\s@]+@[^\s@]+\.[^\s@]+(,[^\s@]+@[^\s@]+\.[^\s@]+)*$/, 'Each recipient must be a valid email').optional(),
format: ReportFormatSchema.optional(),
isActive: z.boolean().optional(),
}))
.mutation(async ({ input, ctx }) => {
const { id, ...updates } = input;
const existing = await ctx.db!
.select({ id: scheduledReports.id, createdBy: scheduledReports.createdBy })
.from(scheduledReports)
.where(eq(scheduledReports.id, id))
.limit(1);
const report = existing[0];
if (!report) {
throw new (await import('./router')).TRPCError({ code: 'NOT_FOUND', message: 'Scheduled report not found' });
}
if (report.createdBy !== ctx.userId) {
throw new (await import('./router')).TRPCError({ code: 'FORBIDDEN', message: 'Not the report owner' });
}
const result = await ctx.db!
.update(scheduledReports)
.set({ ...updates, updatedAt: new Date() })
.where(eq(scheduledReports.id, id))
.returning();
return result[0];
}),
// --- Cohort Endpoints ---
getCohorts: publicProcedure
.input(z.object({
periodStart: z.string().datetime().optional(),
periodEnd: z.string().datetime().optional(),
}))
.query(async ({ input, ctx }) => {
return await listCohorts(
ctx.db!,
input.periodStart ? new Date(input.periodStart) : undefined,
input.periodEnd ? new Date(input.periodEnd) : undefined,
);
}),
createCohort: protectedProcedure
.input(z.object({
name: z.string().min(1).max(200),
description: z.string().max(1000).optional(),
periodStart: z.string().datetime(),
periodEnd: z.string().datetime().optional(),
filterCriteria: z.record(z.string(), z.unknown()),
}))
.mutation(async ({ input, ctx }) => {
const cohort = await createCohort(ctx.db!, {
name: input.name,
description: input.description || '',
periodStart: new Date(input.periodStart),
periodEnd: input.periodEnd ? new Date(input.periodEnd) : undefined,
filterCriteria: input.filterCriteria,
createdBy: ctx.userId,
});
return cohort;
}),
addCohortMember: protectedProcedure
.input(z.object({
cohortId: z.number().int().positive(),
userId: z.number().int().positive(),
}))
.mutation(async ({ input, ctx }) => {
const existing = await ctx.db!
.select({ id: cohorts.id, createdBy: cohorts.createdBy })
.from(cohorts)
.where(eq(cohorts.id, input.cohortId))
.limit(1);
const cohort = existing[0];
if (!cohort) {
throw new (await import('./router')).TRPCError({ code: 'NOT_FOUND', message: 'Cohort not found' });
}
if (cohort.createdBy !== ctx.userId) {
throw new (await import('./router')).TRPCError({ code: 'FORBIDDEN', message: 'Not the cohort owner' });
}
await addCohortMember(ctx.db!, input.cohortId, input.userId);
const size = await getCohortSize(ctx.db!, input.cohortId);
return { success: true, cohortSize: size };
}),
getCohortAnalysis: publicProcedure
.input(z.object({ cohortId: z.number().int().positive() }))
.query(async ({ input, ctx }) => {
return await getCohortAnalysis(ctx.db!, input.cohortId);
}),
getCohortTemplates: publicProcedure
.input(z.object({
type: z.enum(['monthly', 'weekly', 'feature']),
featureName: z.string().max(100).optional(),
}))
.query(async ({ input }) => {
switch (input.type) {
case 'monthly':
return createMonthlyCohortTemplate();
case 'weekly':
return createWeeklyCohortTemplate();
case 'feature':
return createFeatureCohortTemplate(input.featureName || 'unknown');
default:
return createMonthlyCohortTemplate();
}
}),
// --- NPS Endpoints ---
submitNPSResponse: protectedProcedure
.input(z.object({
score: z.number().int().min(0).max(10),
userId: z.number().int().positive().optional(),
feedback: z.string().max(2000).optional(),
surveyId: z.string().max(100).optional(),
respondentEmail: z.string().email().max(200).optional(),
}))
.mutation(async ({ input, ctx }) => {
const response = await submitNPSResponse(ctx.db!, {
score: input.score as NPSScore,
userId: input.userId,
feedback: input.feedback,
surveyId: input.surveyId,
respondentEmail: input.respondentEmail,
});
return response;
}),
calculateNPS: publicProcedure
.input(z.object({
periodStart: z.string().datetime().optional(),
periodEnd: z.string().datetime().optional(),
}))
.query(async ({ input, ctx }) => {
return await calculateNPS(
ctx.db!,
input.periodStart ? new Date(input.periodStart) : undefined,
input.periodEnd ? new Date(input.periodEnd) : undefined,
);
}),
getNPSResponses: protectedProcedure
.input(z.object({
category: z.enum(['detractor', 'passive', 'promoter']).optional(),
periodStart: z.string().datetime().optional(),
periodEnd: z.string().datetime().optional(),
limit: z.number().int().min(1).max(200).default(50),
}))
.query(async ({ input, ctx }) => {
return await getNPSResponses(
ctx.db!,
input.category,
input.periodStart ? new Date(input.periodStart) : undefined,
input.periodEnd ? new Date(input.periodEnd) : undefined,
input.limit,
);
}),
getNPSOverTime: publicProcedure
.input(z.object({
granularity: z.enum(['weekly', 'monthly']).default('weekly'),
}))
.query(async ({ input, ctx }) => {
return await getNPSOverTime(ctx.db!, input.granularity);
}),
getNPSSurveyPrompt: publicProcedure.query(() => {
return generateNPSSurveyInAppPrompt();
}),
};

View File

@@ -1,85 +0,0 @@
import { publicProcedure } from './router';
import { z } from 'zod';
import { eq } from 'drizzle-orm';
import { waitlistSignups, waitlistEvents } from '../../src/db/schema';
export const betaRouter = {
signup: publicProcedure
.input(z.object({
name: z.string().min(1).max(200),
email: z.string().email(),
primaryRole: z.string().max(100),
scriptsWritten: z.string().max(50).optional(),
currentSoftware: z.string().max(100).optional(),
softwareLove: z.string().max(2000).optional(),
softwareFrustrate: z.string().max(2000).optional(),
hoursPerWeek: z.string().max(50).optional(),
willingFeedback: z.string().max(100).optional(),
joinDiscord: z.string().max(100).optional(),
discordUsername: z.string().max(100).optional(),
excitedFeatures: z.array(z.string()).optional(),
heardAbout: z.string().max(100).optional(),
additionalInfo: z.string().max(2000).optional(),
utmSource: z.string().max(100).optional(),
utmMedium: z.string().max(100).optional(),
utmCampaign: z.string().max(100).optional(),
utmContent: z.string().max(100).optional(),
utmTerm: z.string().max(100).optional(),
}))
.mutation(async ({ input, ctx }) => {
const existingRows = await ctx.db!.select()
.from(waitlistSignups)
.where(eq(waitlistSignups.email, input.email.toLowerCase()));
const existing = existingRows[0];
if (existing) {
return { success: true, alreadyApplied: true, id: existing.id };
}
const betaData = {
primaryRole: input.primaryRole,
scriptsWritten: input.scriptsWritten,
currentSoftware: input.currentSoftware,
softwareLove: input.softwareLove,
softwareFrustrate: input.softwareFrustrate,
hoursPerWeek: input.hoursPerWeek,
willingFeedback: input.willingFeedback,
joinDiscord: input.joinDiscord,
discordUsername: input.discordUsername,
excitedFeatures: input.excitedFeatures?.join(', ') || '',
heardAbout: input.heardAbout,
additionalInfo: input.additionalInfo,
utmSource: input.utmSource,
utmMedium: input.utmMedium,
utmCampaign: input.utmCampaign,
utmContent: input.utmContent,
utmTerm: input.utmTerm,
};
const metadata: Record<string, unknown> = {
isBetaApplication: true,
...betaData,
};
const result = await ctx.db!.insert(waitlistSignups)
.values({
email: input.email.toLowerCase(),
name: input.name,
source: 'beta',
status: 'beta-pending',
metadata: JSON.stringify(metadata),
})
.returning();
const signup = result[0];
await ctx.db!.insert(waitlistEvents)
.values({
signupId: signup!.id,
eventType: 'beta-application',
eventData: JSON.stringify(betaData),
});
return { success: true, alreadyApplied: false, id: signup!.id };
}),
};

View File

@@ -1,237 +0,0 @@
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: TRPCContext;
let caller: ReturnType<typeof appRouter.createCaller>;
let projectId: number;
beforeEach(async () => {
await resetTestDb();
const db = await getTestDb();
ctx = { clerkUserId: 'user_test', 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 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({
name: 'John Doe',
bio: 'A brave hero',
role: 'protagonist',
arcType: 'positive',
age: 30,
projectId,
});
expect(character.slug).toBe('john-doe');
});
it('should default role to supporting when not provided', async () => {
const character = await caller.project.createCharacter({
name: 'Jane Smith',
projectId,
});
expect(character.role).toBe('supporting');
});
});
describe('updateCharacter', () => {
it('should update character profile fields', async () => {
const created = await caller.project.createCharacter({
name: 'Original',
projectId,
});
const updated = await caller.project.updateCharacter({
id: created.id,
name: 'Updated Name',
bio: 'New bio',
role: 'antagonist',
});
expect(updated.name).toBe('Updated Name');
expect(updated.slug).toBe('updated-name');
expect(updated.bio).toBe('New bio');
expect(updated.role).toBe('antagonist');
});
});
describe('searchCharacters', () => {
it('should filter characters by query', async () => {
await caller.project.createCharacter({
name: 'Alice',
bio: 'The hero',
projectId,
});
await caller.project.createCharacter({
name: 'Bob',
bio: 'The villain',
projectId,
});
const results = await caller.project.searchCharacters({
projectId,
query: 'hero',
});
expect(results.length).toBe(1);
expect(results[0].name).toBe('Alice');
});
it('should filter characters by role', async () => {
await caller.project.createCharacter({
name: 'Protag',
role: 'protagonist',
projectId,
});
await caller.project.createCharacter({
name: 'Antag',
role: 'antagonist',
projectId,
});
const results = await caller.project.searchCharacters({
projectId,
role: 'protagonist',
});
expect(results.length).toBe(1);
expect(results[0].name).toBe('Protag');
});
});
describe('createRelationship', () => {
it('should create a relationship between two characters', async () => {
const charA = await caller.project.createCharacter({
name: 'Character A',
projectId,
});
const charB = await caller.project.createCharacter({
name: 'Character B',
projectId,
});
const rel = await caller.project.createRelationship({
characterIdA: charA.id,
characterIdB: charB.id,
relationshipType: 'friendship',
strength: 80,
isAntagonistic: false,
});
expect(rel.characterIdA).toBe(charA.id);
expect(rel.characterIdB).toBe(charB.id);
expect(rel.relationshipType).toBe('friendship');
expect(rel.strength).toBe(80);
});
it('should prevent self-relationships', async () => {
const charA = await caller.project.createCharacter({
name: 'Character A',
projectId,
});
await expect(
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 caller.project.createCharacter({
name: 'Character A',
projectId,
});
const charB = await caller.project.createCharacter({
name: 'Character B',
projectId,
});
await caller.project.createRelationship({
characterIdA: charA.id,
characterIdB: charB.id,
relationshipType: 'friendship',
});
await expect(
caller.project.createRelationship({
characterIdA: charA.id,
characterIdB: charB.id,
relationshipType: 'rivalry',
})
).rejects.toThrow('Relationship already exists between these characters');
});
});
describe('deleteCharacter', () => {
it('should remove associated relationships when deleting a character', async () => {
const charA = await caller.project.createCharacter({
name: 'Character A',
projectId,
});
const charB = await caller.project.createCharacter({
name: 'Character B',
projectId,
});
await caller.project.createRelationship({
characterIdA: charA.id,
characterIdB: charB.id,
relationshipType: 'friendship',
});
await caller.project.deleteCharacter({ id: charA.id });
const rels = await caller.project.getRelationshipsForCharacter({
characterId: charB.id,
});
expect(rels.length).toBe(0);
});
});
describe('getCharacterStats', () => {
it('should return stats for a character', async () => {
const charA = await caller.project.createCharacter({
name: 'TestChar',
projectId,
});
const stats = await caller.project.getCharacterStats({
characterId: charA.id,
});
expect(stats.characterId).toBe(charA.id);
expect(stats.sceneCount).toBe(0);
expect(stats.totalDialogueLines).toBe(0);
expect(stats.relationshipCount).toBe(0);
});
});
});

View File

@@ -1,76 +0,0 @@
import { createHTTPServer, type CreateHTTPContextOptions } from '@trpc/server/adapters/standalone';
import { verifyToken } from '@clerk/backend';
import { projectRouter } from './project-router';
import { revisionsRouter } from './revisions-router';
import { scriptsRouter } from './scripts-router';
import { waitlistRouter } from './waitlist-router';
import { betaRouter } from './beta-router';
import { mailRouter } from './mail-router';
import { teamRouter } from './team-router';
import { analyticsRouter } from './analytics-router';
import type { TRPCContext } from './types';
import type { TRPCError } from '@trpc/server';
import { t } from './router';
// App router combining all routers
export const appRouter = t.router({
project: projectRouter,
revisions: revisionsRouter,
scripts: scriptsRouter,
waitlist: waitlistRouter,
beta: betaRouter,
mail: mailRouter,
team: teamRouter,
analytics: analyticsRouter,
} as const);
export type AppRouter = typeof appRouter;
async function authenticateRequest(req: CreateHTTPContextOptions['req']): Promise<string | undefined> {
const authHeader = req.headers['authorization'];
if (!authHeader) {
return undefined;
}
const match = authHeader.match(/^Bearer\s+(.+)$/i);
if (!match || !match[1]) {
return undefined;
}
const token = match[1];
try {
const verified = await verifyToken(token, {
secretKey: process.env.CLERK_SECRET_KEY,
});
return verified.sub;
} catch {
return undefined;
}
}
// Create tRPC HTTP server - db is loaded lazily to avoid requiring Turso env vars at import time
export function createTRPCServer(port: number = 8080) {
const server = createHTTPServer({
router: appRouter,
createContext: async (opts: CreateHTTPContextOptions): Promise<TRPCContext> => {
const { db } = await import('../../src/db/config/migrations');
const clerkUserId = await authenticateRequest(opts.req);
return {
clerkUserId,
db,
};
},
onError: ({ error, path }: { error: TRPCError; path: string | undefined }) => {
console.error(`tRPC error on ${path}: [internal error]`);
},
});
server.listen(port, () => {
console.log(`tRPC server listening on port ${port}`);
});
return server;
}
export default appRouter;

View File

@@ -1,155 +0,0 @@
import { z } from 'zod';
import { baseRouter, publicProcedure, protectedProcedure } from './router';
export const mailRouter = baseRouter({
messages: publicProcedure
.input(z.object({
folder: z.string().optional(),
}))
.query(async ({ input, ctx }) => {
// TODO: Implement actual ProtonMail API call
return [] as Array<{
id: string;
subject: string;
sender: { email: string; name?: string };
recipients: Array<{ email: string; name?: string }>;
body: string;
attachments?: Array<{
id: string;
filename: string;
mimeType: string;
size: number;
downloadUrl: string;
}>;
timestamp: string;
read: boolean;
}>;
}),
message: publicProcedure
.input(z.object({
messageId: z.string(),
}))
.query(async ({ input, ctx }) => {
// TODO: Implement actual ProtonMail API call
return {} as {
id: string;
subject: string;
sender: { email: string; name?: string };
recipients: Array<{ email: string; name?: string }>;
body: string;
attachments?: Array<{
id: string;
filename: string;
mimeType: string;
size: number;
downloadUrl: string;
}>;
timestamp: string;
read: boolean;
};
}),
send: protectedProcedure
.input(z.object({
to: z.array(z.string()),
subject: z.string(),
body: z.string(),
attachments: z.array(z.object({
id: z.string(),
filename: z.string(),
mimeType: z.string(),
size: z.number(),
downloadUrl: z.string(),
})).optional(),
}))
.mutation(async ({ input, ctx }) => {
// TODO: Implement actual ProtonMail API call
return {} as {
id: string;
subject: string;
sender: { email: string; name?: string };
recipients: Array<{ email: string; name?: string }>;
body: string;
attachments?: Array<{
id: string;
filename: string;
mimeType: string;
size: number;
downloadUrl: string;
}>;
timestamp: string;
read: boolean;
};
}),
contact: publicProcedure
.input(z.object({
email: z.string().email(),
}))
.query(async ({ input, ctx }) => {
// TODO: Implement actual ProtonMail API call
return null as {
id: string;
email: string;
name: string;
phone?: string;
organization?: string;
} | null;
}),
contacts: publicProcedure
.input(z.object({}))
.query(async ({ ctx }) => {
// TODO: Implement actual ProtonMail API call
return [] as Array<{
id: string;
email: string;
name: string;
phone?: string;
organization?: string;
}>;
}),
addContact: protectedProcedure
.input(z.object({
email: z.string().email(),
name: z.string(),
phone: z.string().optional(),
organization: z.string().optional(),
}))
.mutation(async ({ input, ctx }) => {
// TODO: Implement actual ProtonMail API call
return {} as {
id: string;
email: string;
name: string;
phone?: string;
organization?: string;
};
}),
attachment: publicProcedure
.input(z.object({
attachmentId: z.string(),
}))
.query(async ({ input, ctx }) => {
// TODO: Implement actual ProtonMail API call
return {} as {
id: string;
filename: string;
mimeType: string;
size: number;
downloadUrl: string;
};
}),
attachmentDownload: publicProcedure
.input(z.object({
attachmentId: z.string(),
}))
.query(async ({ input, ctx }) => {
// TODO: Implement actual ProtonMail API call
return new Blob();
}),
});

View File

@@ -1,302 +0,0 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { appRouter } from './index';
import { getTestDb, resetTestDb, globalSqlite } from './test-setup';
import type { TRPCContext } from './types';
describe('tRPC API Layer', () => {
let ctx: TRPCContext;
let caller: ReturnType<typeof appRouter.createCaller>;
let projectId: number;
beforeEach(async () => {
await resetTestDb();
const db = await getTestDb();
ctx = { clerkUserId: 'user_test', db };
caller = appRouter.createCaller(ctx);
});
describe('Project CRUD', () => {
it('should create a project', async () => {
const project = await caller.project.createProject({
name: 'Test Project',
description: 'A test project',
});
expect(project).toMatchObject({
name: 'Test Project',
description: 'A test project',
ownerId: 1,
});
expect(project.id).toBeDefined();
expect(project.id).toBeGreaterThan(0);
expect(project.createdAt).toBeInstanceOf(Date);
expect(project.updatedAt).toBeInstanceOf(Date);
});
it('should list projects', async () => {
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 () => {
const created = await caller.project.createProject({ name: 'Get Test' });
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 caller.project.createProject({ name: 'Update Test' });
const updated = await caller.project.updateProject({
id: created.id,
name: 'Updated Test',
description: 'Updated description',
});
expect(updated.name).toBe('Updated Test');
expect(updated.description).toBe('Updated description');
});
it('should delete a project', async () => {
const created = await caller.project.createProject({ name: 'Delete Test' });
const result = await caller.project.deleteProject({ id: created.id });
expect(result).toEqual({ success: true });
});
});
describe('Character CRUD', () => {
beforeEach(async () => {
const project = await caller.project.createProject({
name: 'Character Test Project',
});
projectId = project.id;
});
it('should create a character', async () => {
const character = await caller.project.createCharacter({
name: 'John Doe',
description: 'Main character',
projectId,
});
expect(character).toMatchObject({
name: 'John Doe',
description: 'Main character',
projectId,
});
});
it('should list characters for a project', async () => {
await caller.project.createCharacter({ name: 'Char 1', projectId });
const characters = await caller.project.listCharacters({ projectId });
expect(characters.length).toBeGreaterThan(0);
});
});
describe('Scene CRUD', () => {
beforeEach(async () => {
const project = await caller.project.createProject({
name: 'Scene Test Project',
});
projectId = project.id;
});
it('should create a scene', async () => {
const scene = await caller.project.createScene({
title: 'INT. OFFICE - DAY',
content: 'John sits at his desk.',
projectId,
order: 1,
});
expect(scene).toMatchObject({
title: 'INT. OFFICE - DAY',
content: 'John sits at his desk.',
projectId,
order: 1,
});
});
it('should list scenes for a project', async () => {
await caller.project.createScene({ title: 'Scene 1', projectId, order: 1 });
const scenes = await caller.project.listScenes({ projectId });
expect(scenes.length).toBeGreaterThan(0);
});
it('should update scene order', async () => {
const scene = await caller.project.createScene({
title: 'Reorder Scene',
projectId,
order: 1,
});
const updated = await caller.project.updateScene({ id: scene.id, order: 5 });
expect(updated.order).toBe(5);
});
});
describe('Error Handling', () => {
it('should throw error when getting non-existent project', async () => {
await expect(
caller.project.getProject({ id: 99999 })
).rejects.toThrow('not found');
});
it('should throw error when deleting non-existent project', async () => {
await expect(
caller.project.deleteProject({ id: 99999 })
).rejects.toThrow('not found');
});
});
describe('Project Sharing', () => {
let sharedProjectId: number;
beforeEach(async () => {
const project = await caller.project.createProject({
name: 'Shared Project',
});
sharedProjectId = project.id;
// Insert a second user
globalSqlite!.exec("INSERT INTO users (id, clerk_id, email, name) VALUES (2, 'user2_test', 'user2@test.com', 'User Two');");
});
it('should share a project with another user', async () => {
const member = await caller.project.shareProject({
projectId: sharedProjectId,
userId: 2,
role: 'editor',
});
expect(member).toMatchObject({
projectId: sharedProjectId,
userId: 2,
role: 'editor',
});
});
it('should list project members including owner', async () => {
await caller.project.shareProject({
projectId: sharedProjectId,
userId: 2,
role: 'viewer',
});
const members = await caller.project.listMembers({ projectId: sharedProjectId });
expect(members.length).toBeGreaterThanOrEqual(2);
const owner = members.find((m: any) => m.userId === 1 && m.role === 'owner');
const member = members.find((m: any) => m.userId === 2 && m.role === 'viewer');
expect(owner).toBeDefined();
expect(member).toBeDefined();
});
it('should update a member role', async () => {
await caller.project.shareProject({
projectId: sharedProjectId,
userId: 2,
role: 'viewer',
});
const updated = await caller.project.updateMemberRole({
projectId: sharedProjectId,
userId: 2,
role: 'admin',
});
expect(updated.role).toBe('admin');
});
it('should remove a member', async () => {
await caller.project.shareProject({
projectId: sharedProjectId,
userId: 2,
role: 'editor',
});
const result = await caller.project.removeMember({
projectId: sharedProjectId,
userId: 2,
});
expect(result).toEqual({ success: true });
const members = await caller.project.listMembers({ projectId: sharedProjectId });
const removed = members.find((m: any) => m.userId === 2);
expect(removed).toBeUndefined();
});
it('should throw error when sharing with yourself', async () => {
await expect(
caller.project.shareProject({
projectId: sharedProjectId,
userId: 1,
role: 'editor',
})
).rejects.toThrow('yourself');
});
it('should throw error when sharing duplicate user', async () => {
await caller.project.shareProject({
projectId: sharedProjectId,
userId: 2,
role: 'editor',
});
await expect(
caller.project.shareProject({
projectId: sharedProjectId,
userId: 2,
role: 'viewer',
})
).rejects.toThrow('already a member');
});
it('should allow shared members to access project', async () => {
await caller.project.shareProject({
projectId: sharedProjectId,
userId: 2,
role: 'editor',
});
// Create caller for user 2
const db = await getTestDb();
const ctx2: TRPCContext = { clerkUserId: 'user2_test', db };
const caller2 = appRouter.createCaller(ctx2);
const project = await caller2.project.getProject({ id: sharedProjectId });
expect(project.id).toBe(sharedProjectId);
});
it('should include shared projects in listProjects for member', async () => {
await caller.project.shareProject({
projectId: sharedProjectId,
userId: 2,
role: 'viewer',
});
const db = await getTestDb();
const ctx2: TRPCContext = { clerkUserId: 'user2_test', db };
const caller2 = appRouter.createCaller(ctx2);
const projects = await caller2.project.listProjects();
const found = projects.find((p: any) => p.id === sharedProjectId);
expect(found).toBeDefined();
});
});
});

View File

@@ -1,831 +0,0 @@
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));
// M2 fix: remove project members
await ctx.db!.delete(projectMembers)
.where(eq(projectMembers.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 TRPCError({ code: 'CONFLICT', message: '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 };
}),
};

View File

@@ -1,246 +0,0 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { appRouter } from './index';
import { getTestDb, resetTestDb } from './test-setup';
import { resetInMemoryState } from './revisions-router';
import type { TRPCContext } from './types';
describe('revisionsRouter', () => {
let ctx: TRPCContext;
let caller: ReturnType<typeof appRouter.createCaller>;
beforeEach(async () => {
await resetTestDb();
const db = await getTestDb();
await resetInMemoryState(db);
ctx = { clerkUserId: 'user_test', db };
caller = appRouter.createCaller(ctx);
});
describe('createRevision', () => {
it('should create a revision with version 1', async () => {
const result = await caller.revisions.createRevision({
scriptId: 1,
title: 'Initial draft',
content: 'FADE IN:\n\nINT. ROOM - DAY',
});
expect(result.versionNumber).toBe(1);
expect(result.branchName).toBe('main');
expect(result.status).toBe('draft');
expect(result.authorId).toBe(1);
});
it('should increment version number for same script', async () => {
await caller.revisions.createRevision({
scriptId: 1,
title: 'v1',
content: 'content1',
});
const result = await caller.revisions.createRevision({
scriptId: 1,
title: 'v2',
content: 'content2',
});
expect(result.versionNumber).toBe(2);
});
it('should support custom branch', async () => {
const result = await caller.revisions.createRevision({
scriptId: 1,
title: 'Branch revision',
content: 'branch content',
branchName: 'feature-act2',
});
expect(result.branchName).toBe('feature-act2');
});
});
describe('listRevisions', () => {
it('should return empty array for script with no revisions', async () => {
const result = await caller.revisions.listRevisions({ scriptId: 1 });
expect(result).toEqual([]);
});
it('should throw NOT_FOUND for unknown script', async () => {
const { TRPCError } = await import('./router');
await expect(
caller.revisions.listRevisions({ scriptId: 999 })
).rejects.toThrow(TRPCError);
});
it('should filter by branch', async () => {
await caller.revisions.createRevision({
scriptId: 1,
title: 'main v1',
content: 'main',
branchName: 'main',
});
await caller.revisions.createRevision({
scriptId: 1,
title: 'feature v1',
content: 'feature',
branchName: 'feature',
});
const mainRevisions = await caller.revisions.listRevisions({
scriptId: 1,
branchName: 'main',
});
expect(mainRevisions).toHaveLength(1);
expect(mainRevisions[0]!.branchName).toBe('main');
});
});
describe('acceptRevision', () => {
it('should accept a revision', async () => {
const created = await caller.revisions.createRevision({
scriptId: 1,
title: 'To accept',
content: 'content',
});
const result = await caller.revisions.acceptRevision({
revisionId: created.id,
});
expect(result.status).toBe('accepted');
expect(result.reviewedById).toBe(1);
expect(result.reviewedAt).toBeDefined();
});
});
describe('rejectRevision', () => {
it('should reject a revision with reason', async () => {
const created = await caller.revisions.createRevision({
scriptId: 1,
title: 'To reject',
content: 'content',
});
const result = await caller.revisions.rejectRevision({
revisionId: created.id,
reason: 'Needs more work on dialogue',
});
expect(result.status).toBe('rejected');
expect(result.summary).toContain('Needs more work on dialogue');
});
});
describe('rollbackToRevision', () => {
it('should create a new revision with old content', async () => {
const original = await caller.revisions.createRevision({
scriptId: 1,
title: 'Original',
content: 'original content',
});
await caller.revisions.createRevision({
scriptId: 1,
title: 'Changed',
content: 'changed content',
});
const rollback = await caller.revisions.rollbackToRevision({
scriptId: 1,
revisionId: original.id,
});
expect(rollback.content).toBe('original content');
expect(rollback.versionNumber).toBe(3);
expect(rollback.title).toContain('Rollback');
});
});
describe('compareRevisions', () => {
it('should compare two revisions', async () => {
const rev1 = await caller.revisions.createRevision({
scriptId: 1,
title: 'v1',
content: 'line1\nline2\nline3',
});
const rev2 = await caller.revisions.createRevision({
scriptId: 1,
title: 'v2',
content: 'line1\nchanged\nline3',
});
const result = await caller.revisions.compareRevisions({
baseRevisionId: rev1.id,
targetRevisionId: rev2.id,
});
expect(result.diff.modifications).toBe(1);
expect(result.diff.additions).toBe(0);
expect(result.diff.deletions).toBe(0);
});
});
describe('getTimeline', () => {
it('should return timeline entries in chronological order', async () => {
await caller.revisions.createRevision({
scriptId: 1,
title: 'First',
content: 'first',
});
await caller.revisions.createRevision({
scriptId: 1,
title: 'Second',
content: 'second',
});
const timeline = await caller.revisions.getTimeline({ scriptId: 1 });
expect(timeline).toHaveLength(2);
expect(timeline[0]!.revision.title).toBe('First');
expect(timeline[1]!.revision.title).toBe('Second');
});
});
describe('getBranches', () => {
it('should return branch information', async () => {
await caller.revisions.createRevision({
scriptId: 1,
title: 'main v1',
content: 'main',
});
await caller.revisions.createBranch({
scriptId: 1,
branchName: 'feature',
});
const branches = await caller.revisions.getBranches({ scriptId: 1 });
expect(branches).toHaveLength(2);
const branchNames = branches.map((b: any) => b.branchName);
expect(branchNames).toContain('main');
expect(branchNames).toContain('feature');
});
});
describe('deleteRevision', () => {
it('should delete a revision', async () => {
const created = await caller.revisions.createRevision({
scriptId: 1,
title: 'To delete',
content: 'content',
});
const result = await caller.revisions.deleteRevision({ id: created.id });
expect(result.success).toBe(true);
await expect(
caller.revisions.getRevision({ id: created.id })
).rejects.toThrow();
});
});
});

View File

@@ -1,660 +0,0 @@
import { protectedProcedure, TRPCError } from './router';
import { z } from 'zod';
import { eq, and, or, like, sql, desc, asc } from 'drizzle-orm';
import type { DrizzleDB } from '../../src/db/config/migrations';
import {
revisions,
revisionChanges,
scripts,
projects,
projectMembers,
} from '../../src/db/schema';
// H1 fix: verifies user has access to the project owning the script a revision belongs to
async function verifyScriptAccess(
db: DrizzleDB,
scriptId: number,
userId: number
) {
const scriptRows = await db
.select({ id: scripts.id, projectId: scripts.projectId })
.from(scripts)
.where(eq(scripts.id, scriptId));
const script = scriptRows[0];
if (!script) {
throw new TRPCError({ code: 'NOT_FOUND', message: `Script ${scriptId} not found` });
}
const projectRows = await db
.select({ id: projects.id, ownerId: projects.ownerId })
.from(projects)
.where(eq(projects.id, script.projectId));
const project = projectRows[0];
if (!project) {
throw new TRPCError({ code: 'NOT_FOUND', message: `Project ${script.projectId} not found` });
}
if (project.ownerId === userId) return { script, project };
const memberRows = await db
.select()
.from(projectMembers)
.where(and(eq(projectMembers.projectId, script.projectId), eq(projectMembers.userId, userId)));
if (memberRows.length === 0) {
throw new TRPCError({ code: 'FORBIDDEN', message: `You do not have access to script ${scriptId}` });
}
return { script, project };
}
// Resolves revision → script → project and verifies user access
async function verifyRevisionAccess(
db: DrizzleDB,
revisionId: number,
userId: number
) {
const revisionRows = await db
.select()
.from(revisions)
.where(eq(revisions.id, revisionId));
const revision = revisionRows[0];
if (!revision) {
throw new TRPCError({ code: 'NOT_FOUND', message: `Revision ${revisionId} not found` });
}
const { script, project } = await verifyScriptAccess(db, revision.scriptId, userId);
return { revision, script, project };
}
function computeDiffForContent(
db: DrizzleDB,
oldContent: string,
newContent: string,
revisionId: number
) {
const oldLines = oldContent.split('\n');
const newLines = newContent.split('\n');
const linesPerPage = 55;
const maxLen = Math.max(oldLines.length, newLines.length);
let sceneCounter = 0;
const changesToInsert = [];
for (let i = 0; i < maxLen; i++) {
const oldLine = oldLines[i];
const newLine = newLines[i];
if (oldLine === newLine) continue;
let changeType: 'addition' | 'deletion' | 'modification';
if (!oldLine && newLine) {
changeType = 'addition';
} else if (oldLine && !newLine) {
changeType = 'deletion';
} else {
changeType = 'modification';
}
if (newLine?.trim().toUpperCase().startsWith('INT.') ||
newLine?.trim().toUpperCase().startsWith('EXT.')) {
sceneCounter++;
}
changesToInsert.push({
revisionId,
changeType,
elementType: null,
oldContent: changeType !== 'addition' ? oldLine || null : null,
newContent: changeType !== 'deletion' ? newLine || null : null,
sceneNumber: sceneCounter || null,
lineNumber: i + 1,
pageNumber: Math.ceil((i + 1) / linesPerPage),
});
}
if (changesToInsert.length > 0) {
return db.insert(revisionChanges).values(changesToInsert).returning();
}
return [];
}
async function getLatestVersionForScript(
db: DrizzleDB,
scriptId: number,
branchName: string
): Promise<number> {
const maxResult = await db
.select({ maxVersion: sql<number>`MAX(${revisions.versionNumber})` })
.from(revisions)
.where(and(eq(revisions.scriptId, scriptId), eq(revisions.branchName, branchName)));
return maxResult[0]?.maxVersion ?? 0;
}
// Export reset function for testing
export async function resetInMemoryState(db: DrizzleDB) {
await db.delete(revisionChanges);
await db.delete(revisions);
}
// Helper to get next revision ID
async function getNextRevisionId(db: DrizzleDB): Promise<number> {
const result = await db
.select({ maxId: sql<number>`MAX(${revisions.id})` })
.from(revisions);
return (result[0]?.maxId ?? 0) + 1;
}
export const revisionsRouter = {
listRevisions: protectedProcedure
.input(z.object({
scriptId: z.number().int().positive(),
branchName: z.string().optional(),
}))
.query(async ({ input, ctx }) => {
await verifyScriptAccess(ctx.db!, input.scriptId, ctx.userId!);
const conditions = [eq(revisions.scriptId, input.scriptId)];
if (input.branchName) {
conditions.push(eq(revisions.branchName, input.branchName));
}
const results = await ctx.db!
.select()
.from(revisions)
.where(and(...conditions))
.orderBy(desc(revisions.versionNumber));
return results;
}),
getRevision: protectedProcedure
.input(z.object({
id: z.number().int().positive(),
}))
.query(async ({ input, ctx }) => {
const { revision } = await verifyRevisionAccess(ctx.db!, input.id, ctx.userId!);
return revision;
}),
createRevision: protectedProcedure
.input(z.object({
scriptId: z.number().int().positive(),
title: z.string().min(1).max(255),
summary: z.string().max(2000).optional(),
content: z.string().max(100000),
branchName: z.string().default('main'),
parentRevisionId: z.number().int().positive().optional(),
}))
.mutation(async ({ input, ctx }) => {
if (!ctx.userId) {
throw new TRPCError({ code: 'UNAUTHORIZED', message: 'User not authenticated' });
}
await verifyScriptAccess(ctx.db!, input.scriptId, ctx.userId!);
const nextVersion = await getLatestVersionForScript(
ctx.db,
input.scriptId,
input.branchName
) + 1;
const nextId = await getNextRevisionId(ctx.db);
const result = await ctx.db
.insert(revisions)
.values({
id: nextId,
scriptId: input.scriptId,
versionNumber: nextVersion,
branchName: input.branchName,
parentRevisionId: input.parentRevisionId || null,
title: input.title,
summary: input.summary || null,
content: input.content,
authorId: ctx.userId,
status: 'draft',
reviewedById: null,
reviewedAt: null,
createdAt: new Date(),
updatedAt: new Date(),
})
.returning();
const revision = result[0]!;
if (input.parentRevisionId) {
const parentResult = await ctx.db
.select()
.from(revisions)
.where(eq(revisions.id, input.parentRevisionId));
const parent = parentResult[0];
if (parent) {
await computeDiffForContent(ctx.db, parent.content, input.content, revision.id);
}
}
return revision;
}),
updateRevision: protectedProcedure
.input(z.object({
id: z.number().int().positive(),
title: z.string().min(1).max(255).optional(),
summary: z.string().max(2000).optional(),
content: z.string().optional(),
status: z.enum(['draft', 'pending_review', 'accepted', 'rejected']).optional(),
}))
.mutation(async ({ input, ctx }) => {
const { revision } = await verifyRevisionAccess(ctx.db!, input.id, ctx.userId!);
const updated = await ctx.db
.update(revisions)
.set({
title: input.title ?? revision.title,
summary: input.summary ?? revision.summary,
content: input.content ?? revision.content,
status: input.status ?? revision.status,
updatedAt: new Date(),
})
.where(eq(revisions.id, input.id))
.returning();
return updated[0]!;
}),
deleteRevision: protectedProcedure
.input(z.object({
id: z.number().int().positive(),
}))
.mutation(async ({ input, ctx }) => {
await verifyRevisionAccess(ctx.db!, input.id, ctx.userId!);
const result = await ctx.db
.delete(revisions)
.where(eq(revisions.id, input.id))
.returning();
await ctx.db
.delete(revisionChanges)
.where(eq(revisionChanges.revisionId, input.id));
return { success: true };
}),
getRevisionChanges: protectedProcedure
.input(z.object({
revisionId: z.number().int().positive(),
}))
.query(async ({ input, ctx }) => {
await verifyRevisionAccess(ctx.db!, input.revisionId, ctx.userId!);
const changes = await ctx.db
.select()
.from(revisionChanges)
.where(eq(revisionChanges.revisionId, input.revisionId))
.orderBy(asc(revisionChanges.lineNumber));
return changes;
}),
compareRevisions: protectedProcedure
.input(z.object({
baseRevisionId: z.number().int().positive(),
targetRevisionId: z.number().int().positive(),
}))
.query(async ({ input, ctx }) => {
const { revision: baseRevision } = await verifyRevisionAccess(ctx.db!, input.baseRevisionId, ctx.userId!);
const { revision: targetRevision } = await verifyRevisionAccess(ctx.db!, input.targetRevisionId, ctx.userId!);
const oldLines = baseRevision.content.split('\n');
const newLines = targetRevision.content.split('\n');
let additions = 0;
let deletions = 0;
let modifications = 0;
const maxLen = Math.max(oldLines.length, newLines.length);
for (let i = 0; i < maxLen; i++) {
const oldLine = oldLines[i];
const newLine = newLines[i];
if (oldLine === newLine) continue;
if (!oldLine && newLine) {
additions++;
} else if (oldLine && !newLine) {
deletions++;
} else {
modifications++;
}
}
return {
baseRevision,
targetRevision,
diff: {
additions,
deletions,
modifications,
},
};
}),
acceptRevision: protectedProcedure
.input(z.object({
revisionId: z.number().int().positive(),
}))
.mutation(async ({ input, ctx }) => {
if (!ctx.userId) {
throw new TRPCError({ code: 'UNAUTHORIZED', message: 'User not authenticated' });
}
await verifyRevisionAccess(ctx.db!, input.revisionId, ctx.userId!);
const updated = await ctx.db
.update(revisions)
.set({
status: 'accepted',
reviewedById: ctx.userId,
reviewedAt: new Date(),
updatedAt: new Date(),
})
.where(eq(revisions.id, input.revisionId))
.returning();
return updated[0]!;
}),
rejectRevision: protectedProcedure
.input(z.object({
revisionId: z.number().int().positive(),
reason: z.string().max(1000).optional(),
}))
.mutation(async ({ input, ctx }) => {
if (!ctx.userId) {
throw new TRPCError({ code: 'UNAUTHORIZED', message: 'User not authenticated' });
}
const { revision } = await verifyRevisionAccess(ctx.db!, input.revisionId, ctx.userId!);
const newSummary = input.reason
? (revision.summary || '') + '\n[Rejected: ' + input.reason + ']'
: revision.summary;
const updated = await ctx.db
.update(revisions)
.set({
status: 'rejected',
reviewedById: ctx.userId,
reviewedAt: new Date(),
summary: newSummary,
updatedAt: new Date(),
})
.where(eq(revisions.id, input.revisionId))
.returning();
return updated[0]!;
}),
rollbackToRevision: protectedProcedure
.input(z.object({
scriptId: z.number().int().positive(),
revisionId: z.number().int().positive(),
}))
.mutation(async ({ input, ctx }) => {
if (!ctx.userId) {
throw new TRPCError({ code: 'UNAUTHORIZED', message: 'User not authenticated' });
}
await verifyScriptAccess(ctx.db!, input.scriptId, ctx.userId!);
const { revision: targetRevision } = await verifyRevisionAccess(ctx.db!, input.revisionId, ctx.userId!);
if (targetRevision.scriptId !== input.scriptId) {
throw new TRPCError({ code: 'BAD_REQUEST', message: 'Revision does not belong to the specified script' });
}
const nextVersion = await getLatestVersionForScript(
ctx.db,
input.scriptId,
targetRevision.branchName
) + 1;
const nextId = await getNextRevisionId(ctx.db);
const rollbackRevision = await ctx.db
.insert(revisions)
.values({
id: nextId,
scriptId: input.scriptId,
versionNumber: nextVersion,
branchName: targetRevision.branchName,
parentRevisionId: targetRevision.id,
title: `Rollback to v${targetRevision.versionNumber}: ${targetRevision.title}`,
summary: `Rolled back to revision ${targetRevision.id}`,
content: targetRevision.content,
authorId: ctx.userId,
status: 'draft',
reviewedById: null,
reviewedAt: null,
createdAt: new Date(),
updatedAt: new Date(),
})
.returning();
return rollbackRevision[0]!;
}),
getTimeline: protectedProcedure
.input(z.object({
scriptId: z.number().int().positive(),
}))
.query(async ({ input, ctx }) => {
await verifyScriptAccess(ctx.db!, input.scriptId, ctx.userId!);
const scriptRevisions = await ctx.db
.select()
.from(revisions)
.where(eq(revisions.scriptId, input.scriptId))
.orderBy(asc(revisions.createdAt));
const timeline = await Promise.all(
scriptRevisions.map(async (rev) => {
const changes = await ctx.db
.select()
.from(revisionChanges)
.where(eq(revisionChanges.revisionId, rev.id));
const additions = changes.filter(c => c.changeType === 'addition').length;
const deletions = changes.filter(c => c.changeType === 'deletion').length;
const modifications = changes.filter(c => c.changeType === 'modification').length;
return {
revision: rev,
changeCount: changes.length,
additions,
deletions,
modifications,
};
})
);
return timeline;
}),
getBranches: protectedProcedure
.input(z.object({
scriptId: z.number().int().positive(),
}))
.query(async ({ input, ctx }) => {
await verifyScriptAccess(ctx.db!, input.scriptId, ctx.userId!);
const scriptRevisions = await ctx.db
.select()
.from(revisions)
.where(eq(revisions.scriptId, input.scriptId));
const branchMap = new Map<string, typeof scriptRevisions>();
for (const rev of scriptRevisions) {
if (!branchMap.has(rev.branchName)) {
branchMap.set(rev.branchName, []);
}
branchMap.get(rev.branchName)!.push(rev);
}
const branches = Array.from(branchMap.entries()).map(([branchName, revs]) => {
const sorted = revs.sort((a, b) => b.versionNumber - a.versionNumber);
const latest = sorted[0]!;
return {
branchName,
revisionCount: revs.length,
latestVersion: latest.versionNumber,
latestRevision: latest,
};
});
return branches;
}),
createBranch: protectedProcedure
.input(z.object({
scriptId: z.number().int().positive(),
branchName: z.string().min(1),
fromRevisionId: z.number().int().positive().optional(),
}))
.mutation(async ({ input, ctx }) => {
if (!ctx.userId) {
throw new TRPCError({ code: 'UNAUTHORIZED', message: 'User not authenticated' });
}
await verifyScriptAccess(ctx.db!, input.scriptId, ctx.userId!);
const existingResult = await ctx.db
.select({ id: revisions.id })
.from(revisions)
.where(and(
eq(revisions.scriptId, input.scriptId),
eq(revisions.branchName, input.branchName)
));
if (existingResult.length > 0) {
throw new TRPCError({ code: 'CONFLICT', message: `Branch '${input.branchName}' already exists for this script` });
}
let sourceContent = '';
let parentRevisionId: number | null = null;
if (input.fromRevisionId) {
const sourceResult = await ctx.db
.select()
.from(revisions)
.where(eq(revisions.id, input.fromRevisionId));
const source = sourceResult[0];
if (!source) {
throw new TRPCError({ code: 'NOT_FOUND', message: `Source revision ${input.fromRevisionId} not found` });
}
sourceContent = source.content;
parentRevisionId = source.id;
} else {
const mainRevisions = await ctx.db
.select()
.from(revisions)
.where(and(
eq(revisions.scriptId, input.scriptId),
eq(revisions.branchName, 'main')
))
.orderBy(desc(revisions.versionNumber))
.limit(1);
if (mainRevisions.length > 0) {
sourceContent = mainRevisions[0]!.content;
parentRevisionId = mainRevisions[0]!.id;
}
}
const nextId = await getNextRevisionId(ctx.db);
const branchRevision = await ctx.db
.insert(revisions)
.values({
id: nextId,
scriptId: input.scriptId,
versionNumber: 1,
branchName: input.branchName,
parentRevisionId,
title: `Branch: ${input.branchName}`,
summary: null,
content: sourceContent,
authorId: ctx.userId,
status: 'draft',
reviewedById: null,
reviewedAt: null,
createdAt: new Date(),
updatedAt: new Date(),
})
.returning();
return branchRevision[0]!;
}),
mergeBranch: protectedProcedure
.input(z.object({
scriptId: z.number().int().positive(),
sourceBranch: z.string(),
targetBranch: z.string(),
}))
.mutation(async ({ input, ctx }) => {
if (!ctx.userId) {
throw new TRPCError({ code: 'UNAUTHORIZED', message: 'User not authenticated' });
}
await verifyScriptAccess(ctx.db!, input.scriptId, ctx.userId!);
if (input.sourceBranch === input.targetBranch) {
throw new TRPCError({ code: 'BAD_REQUEST', message: 'Cannot merge a branch into itself' });
}
const sourceRevisions = await ctx.db
.select()
.from(revisions)
.where(and(
eq(revisions.scriptId, input.scriptId),
eq(revisions.branchName, input.sourceBranch)
))
.orderBy(desc(revisions.versionNumber))
.limit(1);
if (sourceRevisions.length === 0) {
throw new TRPCError({ code: 'NOT_FOUND', message: `Source branch '${input.sourceBranch}' has no revisions` });
}
const sourceContent = sourceRevisions[0]!.content;
const nextVersion = await getLatestVersionForScript(
ctx.db,
input.scriptId,
input.targetBranch
) + 1;
const nextId = await getNextRevisionId(ctx.db);
const mergeRevision = await ctx.db
.insert(revisions)
.values({
id: nextId,
scriptId: input.scriptId,
versionNumber: nextVersion,
branchName: input.targetBranch,
parentRevisionId: null,
title: `Merge from '${input.sourceBranch}'`,
summary: `Merged ${input.sourceBranch} into ${input.targetBranch}`,
content: sourceContent,
authorId: ctx.userId,
status: 'draft',
reviewedById: null,
reviewedAt: null,
createdAt: new Date(),
updatedAt: new Date(),
})
.returning();
return mergeRevision[0]!;
}),
};

View File

@@ -1,97 +0,0 @@
import { initTRPC, TRPCError } from '@trpc/server';
import { z } from 'zod';
import { eq, and } from 'drizzle-orm';
import { projects, projectMembers } from '../../src/db/schema';
import type { TRPCContext } from './types';
// Initialize tRPC with context
const t = initTRPC.context<TRPCContext>().create();
// Middleware for authentication
const isAuthenticated = t.middleware(({ ctx, next }) => {
if (!ctx.clerkUserId) {
throw new TRPCError({ code: 'UNAUTHORIZED', message: 'User not authenticated' });
}
return next({ ctx: { ...ctx, clerkUserId: ctx.clerkUserId } });
});
// Middleware for database access and user lookup
const hasDb = t.middleware(async ({ ctx, next }) => {
if (!ctx.db) {
throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Database not available' });
}
let userId: number | undefined;
if (ctx.clerkUserId) {
const { users } = await import('../../src/db/schema');
const userRows = await ctx.db.select({ id: users.id })
.from(users)
.where(eq(users.clerkId, ctx.clerkUserId));
if (userRows.length > 0) {
userId = userRows[0].id;
}
}
return next({ ctx: { ...ctx, db: ctx.db, userId } });
});
// Middleware for project ownership verification
const hasProjectAccess = t.middleware(async ({ ctx, next }) => {
if (!ctx.projectId) {
throw new TRPCError({ code: 'FORBIDDEN', message: 'Project access required' });
}
if (!ctx.clerkUserId) {
throw new TRPCError({ code: 'UNAUTHORIZED', message: 'User not authenticated' });
}
if (!ctx.db) {
throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Database not available' });
}
const { users } = await import('../../src/db/schema');
const userRows = await ctx.db.select({ id: users.id, clerkId: users.clerkId })
.from(users)
.where(eq(users.clerkId, ctx.clerkUserId));
const dbUser = userRows[0];
if (!dbUser) {
throw new TRPCError({ code: 'FORBIDDEN', message: 'User mapping not found' });
}
const rows = await ctx.db.select({ id: projects.id, ownerId: projects.ownerId })
.from(projects)
.where(eq(projects.id, ctx.projectId));
const project = rows[0];
if (!project) {
throw new TRPCError({ code: 'NOT_FOUND', message: `Project ${ctx.projectId} not found` });
}
if (project.ownerId === dbUser.id) {
return next({ ctx: { ...ctx, projectId: ctx.projectId, userId: dbUser.id } });
}
// L3 fix: also check project membership
const memberRows = await ctx.db.select()
.from(projectMembers)
.where(and(eq(projectMembers.projectId, ctx.projectId), eq(projectMembers.userId, dbUser.id)));
if (memberRows.length === 0) {
throw new TRPCError({ code: 'FORBIDDEN', message: `You do not have access to project ${ctx.projectId}` });
}
return next({ ctx: { ...ctx, projectId: ctx.projectId, userId: dbUser.id } });
});
// Base router
export const baseRouter = t.router;
// Procedure builders
export const publicProcedure = t.procedure.use(hasDb);
export const protectedProcedure = t.procedure.use(isAuthenticated).use(hasDb);
export const projectProcedure = t.procedure
.use(isAuthenticated)
.use(hasDb)
.use(hasProjectAccess);
// Validation middleware
export const validateInput = <T extends z.ZodTypeAny>(schema: T) => {
return t.middleware(({ input, next }) => {
const validated = schema.parse(input);
return next({ input: validated });
});
};
export { t, TRPCError };

View File

@@ -1,192 +0,0 @@
import { protectedProcedure, TRPCError } from './router';
import { z } from 'zod';
import { eq, and, like, sql, inArray } from 'drizzle-orm';
import type { DrizzleDB } from '../../src/db/config/migrations';
import {
scripts,
revisions,
revisionChanges,
projects,
projectMembers,
} from '../../src/db/schema';
function slugify(title: string): string {
return title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');
}
// H2 fix: verifies user has access to a project (owner or member)
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 verifyScriptOwnership(
db: DrizzleDB,
scriptId: number,
userId: number
) {
const scriptRows = await db.select({ id: scripts.id, projectId: scripts.projectId })
.from(scripts)
.where(eq(scripts.id, scriptId));
const script = scriptRows[0];
if (!script) {
throw new TRPCError({ code: 'NOT_FOUND', message: `Script ${scriptId} not found` });
}
await verifyProjectAccess(db, script.projectId, userId);
return script;
}
export const scriptsRouter = {
listScripts: protectedProcedure
.input(z.object({ projectId: z.number().int().positive() }))
.query(async ({ input, ctx }) => {
await verifyProjectAccess(ctx.db!, input.projectId, ctx.userId!);
return await ctx.db!.select()
.from(scripts)
.where(eq(scripts.projectId, input.projectId))
.orderBy(scripts.updatedAt);
}),
getScript: protectedProcedure
.input(z.object({ id: z.number().int().positive() }))
.query(async ({ input, ctx }) => {
await verifyScriptOwnership(ctx.db!, input.id, ctx.userId!);
const rows = await ctx.db!.select()
.from(scripts)
.where(eq(scripts.id, input.id));
const script = rows[0];
if (!script) {
throw new TRPCError({ code: 'NOT_FOUND', message: `Script ${input.id} not found` });
}
return script;
}),
createScript: protectedProcedure
.input(z.object({
title: z.string().min(1).max(255),
projectId: z.number().int().positive(),
genre: z.string().optional(),
logline: z.string().optional(),
status: z.enum(['draft', 'revision', 'final', 'published']).optional(),
}))
.mutation(async ({ input, ctx }) => {
await verifyProjectAccess(ctx.db!, input.projectId, ctx.userId!);
const result = await ctx.db!.insert(scripts)
.values({
title: input.title,
slug: slugify(input.title),
projectId: input.projectId,
genre: input.genre ?? null,
logline: input.logline ?? null,
status: input.status ?? 'draft',
})
.returning();
return result[0];
}),
updateScript: protectedProcedure
.input(z.object({
id: z.number().int().positive(),
title: z.string().min(1).max(255).optional(),
genre: z.string().optional(),
logline: z.string().optional(),
status: z.enum(['draft', 'revision', 'final', 'published']).optional(),
}))
.mutation(async ({ input, ctx }) => {
await verifyScriptOwnership(ctx.db!, input.id, ctx.userId!);
const updateData: Record<string, any> = { updatedAt: new Date() };
if (input.title !== undefined) {
updateData.title = input.title;
updateData.slug = slugify(input.title);
}
if (input.genre !== undefined) updateData.genre = input.genre ?? null;
if (input.logline !== undefined) updateData.logline = input.logline ?? null;
if (input.status !== undefined) updateData.status = input.status;
const result = await ctx.db!.update(scripts)
.set(updateData)
.where(eq(scripts.id, input.id))
.returning();
return result[0];
}),
deleteScript: protectedProcedure
.input(z.object({ id: z.number().int().positive() }))
.mutation(async ({ input, ctx }) => {
await verifyScriptOwnership(ctx.db!, input.id, ctx.userId!);
// Get revision IDs for this script
const scriptRevisions = await ctx.db!.select({ id: revisions.id })
.from(revisions)
.where(eq(revisions.scriptId, input.id));
// Delete revision changes for each revision
for (const rev of scriptRevisions) {
await ctx.db!.delete(revisionChanges)
.where(eq(revisionChanges.revisionId, rev.id));
}
// Delete revisions
await ctx.db!.delete(revisions)
.where(eq(revisions.scriptId, input.id));
// Delete script
await ctx.db!.delete(scripts)
.where(eq(scripts.id, input.id));
return { success: true };
}),
searchScripts: protectedProcedure
.input(z.object({
projectId: z.number().int().positive(),
query: z.string().optional(),
}))
.query(async ({ input, ctx }) => {
await verifyProjectAccess(ctx.db!, input.projectId, ctx.userId!);
const conditions: any[] = [eq(scripts.projectId, input.projectId)];
if (input.query) {
const q = `%${input.query.toLowerCase()}%`;
conditions.push(
like(sql`LOWER(${scripts.title})`, q),
);
}
return await ctx.db!.select()
.from(scripts)
.where(and(...conditions))
.orderBy(scripts.title);
}),
};

View File

@@ -1,264 +0,0 @@
import { protectedProcedure, TRPCError } from './router';
import { z } from 'zod';
import { eq, and, asc } from 'drizzle-orm';
import type { DrizzleDB } from '../../src/db/config/migrations';
import { teams, teamMembers } from '../../src/db/schema';
async function verifyTeamOwnership(
db: DrizzleDB,
teamId: string,
userId: number
) {
const teamRows = await db.select({ id: teams.id, ownerId: teams.ownerId })
.from(teams)
.where(eq(teams.id, teamId));
const team = teamRows[0];
if (!team) {
throw new TRPCError({ code: 'NOT_FOUND', message: `Team ${teamId} not found` });
}
if (team.ownerId !== userId) {
const memberRows = await db.select()
.from(teamMembers)
.where(and(eq(teamMembers.teamId, teamId), eq(teamMembers.userId, userId)));
if (memberRows.length === 0) {
throw new TRPCError({ code: 'FORBIDDEN', message: `You do not have access to team ${teamId}` });
}
}
return team;
}
async function verifyTeamRole(
db: DrizzleDB,
teamId: string,
userId: number,
allowedRoles: string[]
) {
await verifyTeamOwnership(db, teamId, userId);
const teamRows = await db.select({ id: teams.id, ownerId: teams.ownerId })
.from(teams)
.where(eq(teams.id, teamId));
const team = teamRows[0];
if (!team) return;
if (team.ownerId === userId) return;
const memberRows = await db.select()
.from(teamMembers)
.where(and(eq(teamMembers.teamId, teamId), eq(teamMembers.userId, userId)));
const member = memberRows[0];
if (!member || !allowedRoles.includes(member.role)) {
throw new TRPCError({ code: 'FORBIDDEN', message: 'Insufficient permissions' });
}
}
async function generateTeamId(): Promise<string> {
const { randomUUID } = await import('crypto');
return `team_${randomUUID()}`;
}
export const teamRouter = {
// Team CRUD
listTeams: protectedProcedure.query(async ({ ctx }) => {
const owned = await ctx.db!.select()
.from(teams)
.where(eq(teams.ownerId, ctx.userId!))
.orderBy(asc(teams.createdAt));
const memberRows = await ctx.db!.select({ teamId: teamMembers.teamId })
.from(teamMembers)
.where(eq(teamMembers.userId, ctx.userId!));
const memberTeamIds = new Set(memberRows.map((r) => r.teamId));
const memberTeams: typeof owned = [];
for (const tid of memberTeamIds) {
const row = await ctx.db!.select()
.from(teams)
.where(eq(teams.id, tid))
.then((r) => r[0]);
if (row) memberTeams.push(row);
}
const all = [...owned, ...memberTeams];
const seen = new Set(all.map((t) => t.id));
return all.filter((t) => seen.has(t.id));
}),
getTeam: protectedProcedure
.input(z.object({ id: z.string().min(1) }))
.query(async ({ input, ctx }) => {
await verifyTeamOwnership(ctx.db!, input.id, ctx.userId!);
const rows = await ctx.db!.select()
.from(teams)
.where(eq(teams.id, input.id));
return rows[0];
}),
createTeam: protectedProcedure
.input(z.object({
name: z.string().min(1).max(255),
}))
.mutation(async ({ input, ctx }) => {
const teamId = await generateTeamId();
const result = await ctx.db!.insert(teams)
.values({
id: teamId,
name: input.name,
ownerId: ctx.userId!,
})
.returning();
const team = result[0];
if (!team) {
throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Failed to create team' });
}
await ctx.db!.insert(teamMembers)
.values({
teamId: team.id,
userId: ctx.userId!,
role: 'owner',
});
return team;
}),
updateTeam: protectedProcedure
.input(z.object({
id: z.string().min(1),
name: z.string().min(1).max(255).optional(),
}))
.mutation(async ({ input, ctx }) => {
await verifyTeamRole(ctx.db!, input.id, ctx.userId!, ['owner', 'admin']);
const updateData: Record<string, any> = { updatedAt: new Date() };
if (input.name !== undefined) updateData.name = input.name;
const result = await ctx.db!.update(teams)
.set(updateData)
.where(eq(teams.id, input.id))
.returning();
return result[0];
}),
deleteTeam: protectedProcedure
.input(z.object({ id: z.string().min(1) }))
.mutation(async ({ input, ctx }) => {
await verifyTeamOwnership(ctx.db!, input.id, ctx.userId!);
const teamRows = await ctx.db!.select({ id: teams.id, ownerId: teams.ownerId })
.from(teams)
.where(eq(teams.id, input.id));
if (teamRows[0]?.ownerId !== ctx.userId!) {
throw new TRPCError({ code: 'FORBIDDEN', message: 'Only the owner can delete a team' });
}
await ctx.db!.delete(teamMembers)
.where(eq(teamMembers.teamId, input.id));
await ctx.db!.delete(teams)
.where(eq(teams.id, input.id));
return { success: true };
}),
// Team member management
listMembers: protectedProcedure
.input(z.object({ teamId: z.string().min(1) }))
.query(async ({ input, ctx }) => {
await verifyTeamOwnership(ctx.db!, input.teamId, ctx.userId!);
return await ctx.db!.select()
.from(teamMembers)
.where(eq(teamMembers.teamId, input.teamId))
.orderBy(asc(teamMembers.joinedAt));
}),
addMember: protectedProcedure
.input(z.object({
teamId: z.string().min(1),
userId: z.number().int().positive(),
role: z.enum(['owner', 'admin', 'editor', 'viewer']).default('editor'),
}))
.mutation(async ({ input, ctx }) => {
await verifyTeamRole(ctx.db!, input.teamId, ctx.userId!, ['owner', 'admin']);
const existing = await ctx.db!.select()
.from(teamMembers)
.where(and(eq(teamMembers.teamId, input.teamId), eq(teamMembers.userId, input.userId)));
if (existing.length > 0) {
throw new TRPCError({ code: 'CONFLICT', message: 'User is already a member of this team' });
}
const result = await ctx.db!.insert(teamMembers)
.values({
teamId: input.teamId,
userId: input.userId,
role: input.role,
})
.returning();
return result[0];
}),
updateMemberRole: protectedProcedure
.input(z.object({
teamId: z.string().min(1),
userId: z.number().int().positive(),
role: z.enum(['owner', 'admin', 'editor', 'viewer']),
}))
.mutation(async ({ input, ctx }) => {
await verifyTeamRole(ctx.db!, input.teamId, ctx.userId!, ['owner']);
const result = await ctx.db!.update(teamMembers)
.set({ role: input.role })
.where(and(eq(teamMembers.teamId, input.teamId), eq(teamMembers.userId, input.userId)))
.returning();
return result[0];
}),
removeMember: protectedProcedure
.input(z.object({
teamId: z.string().min(1),
userId: z.number().int().positive(),
}))
.mutation(async ({ input, ctx }) => {
await verifyTeamRole(ctx.db!, input.teamId, ctx.userId!, ['owner', 'admin']);
if (input.userId === ctx.userId!) {
throw new TRPCError({ code: 'FORBIDDEN', message: 'You cannot remove yourself from this team' });
}
const memberRows = await ctx.db!.select()
.from(teamMembers)
.where(and(eq(teamMembers.teamId, input.teamId), eq(teamMembers.userId, input.userId)));
if (memberRows[0]?.role === 'owner') {
throw new TRPCError({ code: 'FORBIDDEN', message: 'Cannot remove the team owner' });
}
await ctx.db!.delete(teamMembers)
.where(and(eq(teamMembers.teamId, input.teamId), eq(teamMembers.userId, input.userId)));
return { success: true };
}),
leaveTeam: protectedProcedure
.input(z.object({ teamId: z.string().min(1) }))
.mutation(async ({ input, ctx }) => {
const memberRows = await ctx.db!.select()
.from(teamMembers)
.where(and(eq(teamMembers.teamId, input.teamId), eq(teamMembers.userId, ctx.userId!)));
if (memberRows[0]?.role === 'owner') {
throw new TRPCError({ code: 'FORBIDDEN', message: 'Owner cannot leave the team. Transfer ownership first.' });
}
await ctx.db!.delete(teamMembers)
.where(and(eq(teamMembers.teamId, input.teamId), eq(teamMembers.userId, ctx.userId!)));
return { success: true };
}),
};

View File

@@ -1,174 +0,0 @@
import { drizzle } from 'drizzle-orm/better-sqlite3';
import Database from 'better-sqlite3';
let testDb: ReturnType<typeof drizzle> | null = null;
let sqlite: Database.Database | null = null;
const schemaSQL = `
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
clerk_id TEXT NOT NULL UNIQUE,
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 project_members (
id INTEGER PRIMARY KEY AUTOINCREMENT,
project_id INTEGER NOT NULL REFERENCES projects(id),
user_id INTEGER NOT NULL REFERENCES users(id),
role TEXT NOT NULL DEFAULT 'editor',
added_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
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),
version_number INTEGER NOT NULL,
branch_name TEXT NOT NULL DEFAULT 'main',
parent_revision_id INTEGER,
title TEXT NOT NULL,
summary TEXT,
content TEXT NOT NULL,
author_id INTEGER NOT NULL REFERENCES users(id),
status TEXT NOT NULL DEFAULT 'draft' CHECK(status IN ('draft', 'pending_review', 'accepted', 'rejected')),
reviewed_by_id INTEGER REFERENCES users(id),
reviewed_at TIMESTAMP,
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 CHECK(change_type IN ('addition', 'deletion', 'modification')),
element_type TEXT,
old_content TEXT,
new_content TEXT,
scene_number INTEGER,
line_number INTEGER,
page_number INTEGER,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS teams (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
owner_id INTEGER NOT NULL REFERENCES users(id),
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS team_members (
id INTEGER PRIMARY KEY AUTOINCREMENT,
team_id TEXT NOT NULL REFERENCES teams(id),
user_id INTEGER NOT NULL REFERENCES users(id),
role TEXT NOT NULL DEFAULT 'editor',
joined_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
`;
export let globalSqlite: Database.Database | null = null;
export async function getTestDb(): Promise<ReturnType<typeof drizzle>> {
if (testDb && sqlite) return testDb;
sqlite = new Database(':memory:');
globalSqlite = sqlite;
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, clerk_id, email, name) VALUES (1, 'user_test', 'test@test.com', 'Test User');");
// Insert a test project
sqlite.exec("INSERT INTO projects (id, name, description, owner_id) VALUES (1, 'Test Project', 'A test project', 1);");
// Insert a test script
sqlite.exec("INSERT INTO scripts (id, project_id, title, version) VALUES (1, 1, 'Test Script', '1.0');");
testDb = drizzle(sqlite);
return testDb;
}
export async function resetTestDb(): Promise<ReturnType<typeof drizzle>> {
testDb = null;
sqlite = null;
globalSqlite = null;
return getTestDb();
}

View File

@@ -1,166 +0,0 @@
import { z } from 'zod';
// Base types - IDs are integers matching Drizzle schema
export const ProjectSchema = z.object({
id: z.number().int().positive(),
name: z.string().min(1).max(255),
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.number().int().positive(),
name: z.string().min(1).max(100),
slug: z.string(),
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.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().nullable(),
strength: z.number().int().min(0).max(100),
isAntagonistic: z.boolean(),
createdAt: z.date(),
updatedAt: z.date(),
});
export const CharacterStatsSchema = z.object({
characterId: z.number().int().positive(),
totalScreenTime: z.number().int(),
totalDialogueLines: z.number().int(),
sceneCount: z.number().int(),
relationshipCount: z.number().int(),
});
export const SceneSchema = z.object({
id: z.number().int().positive(),
title: z.string().min(1),
content: z.string(),
projectId: z.number().int().positive(),
order: z.number().int().nonnegative(),
createdAt: z.date(),
updatedAt: z.date(),
});
// Input schemas
export const CreateProjectInputSchema = z.object({
name: z.string().min(1).max(255),
description: z.string().optional(),
});
export const UpdateProjectInputSchema = z.object({
id: z.number().int().positive(),
name: z.string().min(1).max(255).optional(),
description: z.string().optional(),
});
export const CreateCharacterInputSchema = z.object({
name: z.string().min(1).max(100),
description: z.string().optional(),
bio: z.string().optional(),
role: z.enum(['protagonist', 'antagonist', 'supporting', 'background', 'ensemble']).optional(),
arc: z.string().optional(),
arcType: z.enum(['positive', 'negative', 'flat', 'complex']).optional(),
age: z.number().int().optional(),
gender: z.string().optional(),
voice: z.string().optional(),
traits: z.string().optional(),
motivation: z.string().optional(),
conflict: z.string().optional(),
secret: z.string().optional(),
imageUrl: z.string().url().optional(),
projectId: z.number().int().positive(),
});
export const UpdateCharacterInputSchema = 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(),
});
export const CreateRelationshipInputSchema = 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(),
});
export const UpdateRelationshipInputSchema = 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(),
});
export const SearchCharactersInputSchema = 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(),
});
export const CreateSceneInputSchema = z.object({
title: z.string().min(1),
content: z.string().optional(),
projectId: z.number().int().positive(),
order: z.number().int().nonnegative(),
});
export const UpdateSceneInputSchema = z.object({
id: z.number().int().positive(),
title: z.string().min(1).optional(),
content: z.string().optional(),
order: z.number().int().nonnegative().optional(),
});
// Response schemas
export const ProjectListSchema = z.array(ProjectSchema);
export const CharacterListSchema = z.array(CharacterSchema);
export const CharacterRelationshipListSchema = z.array(CharacterRelationshipSchema);
export const CharacterStatsListSchema = z.array(CharacterStatsSchema);
export const SceneListSchema = z.array(SceneSchema);
// Auth context
export interface TRPCContext {
userId?: number;
clerkUserId?: string;
projectId?: number;
db?: import('../../src/db/config/migrations').DrizzleDB;
}

View File

@@ -1,88 +0,0 @@
import { publicProcedure } from './router';
import { z } from 'zod';
import { eq, sql } from 'drizzle-orm';
import { waitlistSignups, waitlistEvents } from '../../src/db/schema';
function generateReferralCode(length = 8): string {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
let code = '';
for (let i = 0; i < length; i++) {
code += chars.charAt(Math.floor(Math.random() * chars.length));
}
return code;
}
export const waitlistRouter = {
signup: publicProcedure
.input(z.object({
email: z.string().email(),
name: z.string().min(1).max(200).optional(),
source: z.string().max(100).optional().default('organic'),
referralCode: z.string().max(20).optional(),
}))
.mutation(async ({ input, ctx }) => {
const existingRows = await ctx.db!.select()
.from(waitlistSignups)
.where(eq(waitlistSignups.email, input.email.toLowerCase()));
const existing = existingRows[0];
if (existing) {
const metaStr = (existing as Record<string, unknown>).metadata as string | null;
const existingMeta = metaStr ? JSON.parse(metaStr) : {};
return { success: true, alreadyJoined: true, id: existing.id, referralCode: existingMeta.referralCode || null };
}
const metadata: Record<string, unknown> = {};
if (input.referralCode) {
metadata.referredBy = input.referralCode;
}
metadata.referralCode = generateReferralCode();
const result = await ctx.db!.insert(waitlistSignups)
.values({
email: input.email.toLowerCase(),
name: input.name ?? null,
source: input.source ?? 'organic',
metadata: JSON.stringify(metadata),
})
.returning();
const signup = result[0];
await ctx.db!.insert(waitlistEvents)
.values({
signupId: signup!.id,
eventType: 'signup',
eventData: JSON.stringify({ source: input.source, referralCode: input.referralCode }),
});
const referralCode = metadata.referralCode as string;
return { success: true, alreadyJoined: false, id: signup!.id, referralCode };
}),
getCount: publicProcedure
.query(async ({ ctx }) => {
const result = await ctx.db!.select({ count: sql<number>`count(*)` })
.from(waitlistSignups)
.where(eq(waitlistSignups.status, 'waitlist'));
return { count: Number(result[0]!.count) };
}),
getReferralCount: publicProcedure
.input(z.object({ referralCode: z.string().min(1).max(20) }))
.query(async ({ input, ctx }) => {
const rows = await ctx.db!.select({ id: waitlistSignups.id })
.from(waitlistSignups)
.where(eq(waitlistSignups.status, 'waitlist'));
let count = 0;
for (const row of rows) {
const metaStr = (row as Record<string, unknown>).metadata as string | null;
const meta = metaStr ? JSON.parse(metaStr) : {};
if (meta.referredBy === input.referralCode) {
count++;
}
}
return { count };
}),
};