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:
445
server/trpc/analytics-router.ts
Normal file
445
server/trpc/analytics-router.ts
Normal 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();
|
||||
}),
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user