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(); }), };