FRE-622: Wire analytics services to tRPC API layer with comprehensive router

Create analytics-router.ts with ~30 tRPC endpoints for KPI management, alert
rules, scheduled reports, cohort analysis, and NPS survey integration.
Register router in index.ts under 'analytics' namespace. Fix pre-existing
bugs in service files: snake_case to camelCase conversion, missing non-null
assertions, and incorrect DB access patterns.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Paperclip Agent
2026-04-27 22:55:15 -04:00
committed by Michael Freno
parent bc897f8845
commit 408d94f731
6 changed files with 461 additions and 20 deletions

View File

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

View File

@@ -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;

View File

@@ -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,
};

View File

@@ -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<string, NPSResponse[]> = {};
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 {

View File

@@ -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(

View File

@@ -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<void> {
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<DrizzleDB | undefined> {
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);
}
}