Files
FrenoCorp/src/lib/analytics/report-generator.ts
Paperclip Agent 408d94f731 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>
2026-04-27 22:55:15 -04:00

258 lines
8.0 KiB
TypeScript

import { eq, and, gte, lte, desc } from "drizzle-orm";
import { scheduledReports, kpiSnapshots } from "../../db/schema";
import type { DrizzleDB } from "../../db/config/migrations";
import type { NewScheduledReport, ScheduledReport } from "../../db/schema";
import { getAllLatestKPIs, getKPIHistory, getKPIStatus, type KPIKey } from "./kpi-service";
export interface ReportData {
periodStart: Date;
periodEnd: Date;
kpis: Record<string, { value: number; status: "healthy" | "warning" | "critical"; change: number }>;
alerts: string[];
summary: string;
}
export async function generateWeeklyReport(db: DrizzleDB): Promise<ReportData> {
const now = new Date();
const weekAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
const kpis = await getAllLatestKPIs(db);
const kpiData: ReportData["kpis"] = {};
for (const [key, snapshot] of Object.entries(kpis)) {
if (!snapshot) {
kpiData[key] = { value: 0, status: "warning", change: 0 };
continue;
}
const history = await getKPIHistory(db, key as KPIKey, weekAgo, now);
const previousValue = history.length > 1 ? history[history.length - 2]?.kpiValue ?? snapshot.kpiValue : snapshot.kpiValue;
const change = previousValue !== 0 ? ((snapshot.kpiValue - previousValue) / previousValue) * 100 : 0;
const status = getKPIStatus(key as KPIKey, snapshot.kpiValue);
kpiData[key] = { value: snapshot.kpiValue, status, change };
}
const alertMessages = Object.entries(kpiData)
.filter(([, data]) => data.status !== "healthy")
.map(([key, data]) => `⚠️ ${key}: ${data.value.toFixed(2)} (${data.status})`);
const healthyCount = Object.values(kpiData).filter((d) => d.status === "healthy").length;
const totalKPIs = Object.keys(kpiData).length;
const summary = `Weekly Report (${weekAgo.toISOString().split("T")[0]} - ${now.toISOString().split("T")[0]})\n${healthyCount}/${totalKPIs} KPIs healthy. ${alertMessages.length > 0 ? "Alerts: " + alertMessages.join(", ") : "No alerts."}`;
return {
periodStart: weekAgo,
periodEnd: now,
kpis: kpiData,
alerts: alertMessages,
summary,
};
}
export async function generateMonthlyReport(db: DrizzleDB): Promise<ReportData> {
const now = new Date();
const monthAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
const kpis = await getAllLatestKPIs(db);
const kpiData: ReportData["kpis"] = {};
for (const [key, snapshot] of Object.entries(kpis)) {
if (!snapshot) {
kpiData[key] = { value: 0, status: "warning", change: 0 };
continue;
}
const history = await getKPIHistory(db, key as KPIKey, monthAgo, now);
const previousValue = history.length > 1 ? history[history.length - 2]?.kpiValue ?? snapshot.kpiValue : snapshot.kpiValue;
const change = previousValue !== 0 ? ((snapshot.kpiValue - previousValue) / previousValue) * 100 : 0;
const status = getKPIStatus(key as KPIKey, snapshot.kpiValue);
kpiData[key] = { value: snapshot.kpiValue, status, change };
}
const alertMessages = Object.entries(kpiData)
.filter(([, data]) => data.status !== "healthy")
.map(([key, data]) => `⚠️ ${key}: ${data.value.toFixed(2)} (${data.status})`);
const healthyCount = Object.values(kpiData).filter((d) => d.status === "healthy").length;
const totalKPIs = Object.keys(kpiData).length;
const summary = `Monthly Report (${monthAgo.toISOString().split("T")[0]} - ${now.toISOString().split("T")[0]})\n${healthyCount}/${totalKPIs} KPIs healthy.`;
return {
periodStart: monthAgo,
periodEnd: now,
kpis: kpiData,
alerts: alertMessages,
summary,
};
}
export async function formatReportMarkdown(report: ReportData): Promise<string> {
const lines: string[] = [];
lines.push(`# KPI Report`);
lines.push(``);
lines.push(`**Period:** ${report.periodStart.toISOString().split("T")[0]}${report.periodEnd.toISOString().split("T")[0]}`);
lines.push(``);
lines.push(`## Summary`);
lines.push(``);
lines.push(report.summary);
lines.push(``);
lines.push(`## KPI Details`);
lines.push(``);
lines.push(`| KPI | Value | Status | Change |`);
lines.push(`|-----|-------|--------|--------|`);
for (const [key, data] of Object.entries(report.kpis)) {
const statusIcon = data.status === "healthy" ? "✅" : data.status === "warning" ? "⚠️" : "🔴";
const changeStr = data.change >= 0 ? `+${data.change.toFixed(1)}%` : `${data.change.toFixed(1)}%`;
lines.push(`| ${key} | ${data.value.toFixed(2)} | ${statusIcon} ${data.status} | ${changeStr} |`);
}
if (report.alerts.length > 0) {
lines.push(``);
lines.push(`## Alerts`);
lines.push(``);
for (const alert of report.alerts) {
lines.push(`- ${alert}`);
}
}
return lines.join("\n");
}
export async function formatReportSlackBlocks(report: ReportData): Promise<unknown[]> {
const blocks: unknown[] = [
{
type: "header",
text: {
type: "plain_text",
text: `📊 KPI Report`,
emoji: true,
},
},
{
type: "section",
text: {
type: "mrkdwn",
text: `*Period:* ${report.periodStart.toISOString().split("T")[0]}${report.periodEnd.toISOString().split("T")[0]}`,
},
},
];
for (const [key, data] of Object.entries(report.kpis)) {
const statusIcon = data.status === "healthy" ? "✅" : data.status === "warning" ? "⚠️" : "🔴";
const changeStr = data.change >= 0 ? `+${data.change.toFixed(1)}%` : `${data.change.toFixed(1)}%`;
blocks.push({
type: "section",
fields: [
{
type: "mrkdwn",
text: `*${key}:*\n${data.value.toFixed(2)}`,
},
{
type: "mrkdwn",
text: `*Status:*\n${statusIcon} ${data.status}`,
},
{
type: "mrkdwn",
text: `*Change:*\n${changeStr}`,
},
],
});
}
return blocks;
}
export async function createScheduledReport(
db: DrizzleDB,
input: Omit<NewScheduledReport, "lastRunAt" | "nextRunAt">
): Promise<ScheduledReport> {
const result = await db.insert(scheduledReports).values({
...input,
lastRunAt: null,
nextRunAt: computeNextRun(input.schedule),
}).returning();
return result[0]!;
}
export async function getActiveScheduledReports(
db: DrizzleDB
): Promise<ScheduledReport[]> {
return await db
.select()
.from(scheduledReports)
.where(eq(scheduledReports.isActive, true))
.orderBy(scheduledReports.nextRunAt);
}
export async function runDueReports(db: DrizzleDB): Promise<ScheduledReport[]> {
const now = new Date();
const dueReports = await db
.select()
.from(scheduledReports)
.where(and(
eq(scheduledReports.isActive, true),
lte(scheduledReports.nextRunAt, now)
));
const runResults: ScheduledReport[] = [];
for (const report of dueReports) {
let reportData: ReportData;
switch (report.reportType) {
case "weekly_kpi":
reportData = await generateWeeklyReport(db);
break;
case "monthly_kpi":
reportData = await generateMonthlyReport(db);
break;
default:
reportData = await generateWeeklyReport(db);
}
await db
.update(scheduledReports)
.set({
lastRunAt: now,
nextRunAt: computeNextRun(report.schedule),
})
.where(eq(scheduledReports.id, report.id));
runResults.push(report);
}
return runResults;
}
function computeNextRun(schedule: string): Date {
const now = new Date();
switch (schedule) {
case "weekly":
const nextWeek = new Date(now);
nextWeek.setDate(nextWeek.getDate() + 7);
nextWeek.setHours(9, 0, 0, 0);
return nextWeek;
case "monthly":
const nextMonth = new Date(now);
nextMonth.setMonth(nextMonth.getMonth() + 1);
nextMonth.setDate(1);
nextMonth.setHours(9, 0, 0, 0);
return nextMonth;
case "daily":
const tomorrow = new Date(now);
tomorrow.setDate(tomorrow.getDate() + 1);
tomorrow.setHours(9, 0, 0, 0);
return tomorrow;
default:
return new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000);
}
}