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:
@@ -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();
|
||||
}),
|
||||
};
|
||||
@@ -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 };
|
||||
}),
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
}),
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}),
|
||||
};
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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]!;
|
||||
}),
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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);
|
||||
}),
|
||||
};
|
||||
@@ -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 };
|
||||
}),
|
||||
};
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
}),
|
||||
};
|
||||
Reference in New Issue
Block a user