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>
258 lines
8.0 KiB
TypeScript
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);
|
|
}
|
|
}
|