diff --git a/server/trpc/analytics-router.ts b/server/trpc/analytics-router.ts new file mode 100644 index 000000000..982f0c0ee --- /dev/null +++ b/server/trpc/analytics-router.ts @@ -0,0 +1,445 @@ +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: publicProcedure.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: publicProcedure.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, + }).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 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 }) => { + await ctx.db!.delete(alertRules).where(eq(alertRules.id, input.id)); + return { success: true }; + }), + + getAlerts: publicProcedure + .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(), + 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, + }); + 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().optional(), + format: ReportFormatSchema.optional(), + isActive: z.boolean().optional(), + })) + .mutation(async ({ input, ctx }) => { + const { id, ...updates } = input; + 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, + }); + return cohort; + }), + + addCohortMember: protectedProcedure + .input(z.object({ + cohortId: z.number().int().positive(), + userId: z.number().int().positive(), + })) + .mutation(async ({ input, ctx }) => { + 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: publicProcedure + .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: publicProcedure + .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(); + }), +}; diff --git a/server/trpc/index.ts b/server/trpc/index.ts index d4bea991e..13600ab9c 100644 --- a/server/trpc/index.ts +++ b/server/trpc/index.ts @@ -5,6 +5,8 @@ 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'; @@ -17,6 +19,8 @@ export const appRouter = t.router({ waitlist: waitlistRouter, beta: betaRouter, mail: mailRouter, + team: teamRouter, + analytics: analyticsRouter, } as const); export type AppRouter = typeof appRouter; diff --git a/src/lib/analytics/cohort-analysis.ts b/src/lib/analytics/cohort-analysis.ts index 22c08d8a1..5162a284c 100644 --- a/src/lib/analytics/cohort-analysis.ts +++ b/src/lib/analytics/cohort-analysis.ts @@ -40,7 +40,7 @@ export async function createCohort( }; const result = await db.insert(cohorts).values(cohort).returning(); - return result[0]; + return result[0]!; } export async function addCohortMember( @@ -103,7 +103,7 @@ export async function getCohortAnalysis( }; return { - cohort, + cohort: cohort!, retention, metrics, }; diff --git a/src/lib/analytics/nps-service.ts b/src/lib/analytics/nps-service.ts index d4d55aeb8..7255fa346 100644 --- a/src/lib/analytics/nps-service.ts +++ b/src/lib/analytics/nps-service.ts @@ -42,7 +42,7 @@ export async function submitNPSResponse( }; const result = await db.insert(npsResponses).values(response).returning(); - return result[0]; + return result[0]!; } export async function calculateNPS( @@ -122,7 +122,7 @@ export async function getNPSOverTime( const grouped: Record = {}; for (const response of responses) { - const date = response.created_at; + const date = response.createdAt; const key = granularity === "weekly" ? getWeekKey(date) @@ -186,7 +186,7 @@ export function generateNPSSurveyInAppPrompt(): { question: string; scale: strin function getWeekKey(date: Date): string { const start = new Date(date); start.setDate(start.getDate() - start.getDay()); - return start.toISOString().split("T")[0]; + return start.toISOString().split("T")[0]!; } function getMonthKey(date: Date): string { diff --git a/src/lib/analytics/report-generator.ts b/src/lib/analytics/report-generator.ts index 679c0c7fb..97d178549 100644 --- a/src/lib/analytics/report-generator.ts +++ b/src/lib/analytics/report-generator.ts @@ -178,7 +178,7 @@ export async function createScheduledReport( lastRunAt: null, nextRunAt: computeNextRun(input.schedule), }).returning(); - return result[0]; + return result[0]!; } export async function getActiveScheduledReports( diff --git a/src/lib/analytics/slack-alerts.ts b/src/lib/analytics/slack-alerts.ts index fd3240853..78fe6f234 100644 --- a/src/lib/analytics/slack-alerts.ts +++ b/src/lib/analytics/slack-alerts.ts @@ -134,7 +134,7 @@ export async function sendSlackAlert( type: "header", text: { type: "plain_text", - text: `🚨 KPI Alert: ${alert.kpi_key}`, + text: `🚨 KPI Alert: ${alert.kpiKey}`, emoji: true, }, }, @@ -147,7 +147,7 @@ export async function sendSlackAlert( }, { type: "mrkdwn", - text: `*Current Value:*\n${alert.kpi_value.toFixed(2)}`, + text: `*Current Value:*\n${alert.kpiValue.toFixed(2)}`, }, { type: "mrkdwn", @@ -155,7 +155,7 @@ export async function sendSlackAlert( }, { type: "mrkdwn", - text: `*Time:*\n${new Date(alert.created_at?.getTime() ?? Date.now()).toISOString()}`, + text: `*Time:*\n${new Date(alert.createdAt.getTime()).toISOString()}`, }, ], }, @@ -196,22 +196,14 @@ export async function sendSlackAlert( } async function markAlertSent(alertId: number): Promise { - const db = await getDb(); - if (db) { + try { + const { db } = await import("../../db/config/migrations"); await db .update(alerts) .set({ wasSent: true, sentAt: new Date() }) .where(eq(alerts.id, alertId)); - } -} - -async function getDb(): Promise { - try { - const { createDatabaseManager } = await import("../../db/config/database"); - const manager = createDatabaseManager(); - return manager.getDb(); } catch { - return undefined; + console.error("Failed to mark alert as sent:", alertId); } }