H-1: Add createdBy to alertRules, IDOR check on update/delete H-2: Add createdBy to scheduledReports, IDOR check on update H-3: Add createdBy to cohorts, IDOR check on addCohortMember M-1: Change submitNPSResponse to protectedProcedure M-2: Escape Slack Markdown special chars in alert rule names M-3: Change getAllLatestKPIs, getAlertRules, getAlerts, getNPSResponses to protectedProcedure L-2: Add email regex validation to recipients field Co-Authored-By: Paperclip <noreply@paperclip.ing>
497 lines
16 KiB
TypeScript
497 lines
16 KiB
TypeScript
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: protectedProcedure.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: protectedProcedure.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,
|
|
createdBy: ctx.userId,
|
|
}).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 existing = await ctx.db!
|
|
.select({ id: alertRules.id, createdBy: alertRules.createdBy })
|
|
.from(alertRules)
|
|
.where(eq(alertRules.id, id))
|
|
.limit(1);
|
|
const rule = existing[0];
|
|
if (!rule) {
|
|
throw new (await import('./router')).TRPCError({ code: 'NOT_FOUND', message: 'Alert rule not found' });
|
|
}
|
|
if (rule.createdBy !== ctx.userId) {
|
|
throw new (await import('./router')).TRPCError({ code: 'FORBIDDEN', message: 'Not the rule owner' });
|
|
}
|
|
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 }) => {
|
|
const existing = await ctx.db!
|
|
.select({ id: alertRules.id, createdBy: alertRules.createdBy })
|
|
.from(alertRules)
|
|
.where(eq(alertRules.id, input.id))
|
|
.limit(1);
|
|
const rule = existing[0];
|
|
if (!rule) {
|
|
throw new (await import('./router')).TRPCError({ code: 'NOT_FOUND', message: 'Alert rule not found' });
|
|
}
|
|
if (rule.createdBy !== ctx.userId) {
|
|
throw new (await import('./router')).TRPCError({ code: 'FORBIDDEN', message: 'Not the rule owner' });
|
|
}
|
|
await ctx.db!.delete(alertRules).where(eq(alertRules.id, input.id));
|
|
return { success: true };
|
|
}),
|
|
|
|
getAlerts: protectedProcedure
|
|
.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().regex(/^[^\s@]+@[^\s@]+\.[^\s@]+(,[^\s@]+@[^\s@]+\.[^\s@]+)*$/, 'Each recipient must be a valid email'),
|
|
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,
|
|
createdBy: ctx.userId,
|
|
});
|
|
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().regex(/^[^\s@]+@[^\s@]+\.[^\s@]+(,[^\s@]+@[^\s@]+\.[^\s@]+)*$/, 'Each recipient must be a valid email').optional(),
|
|
format: ReportFormatSchema.optional(),
|
|
isActive: z.boolean().optional(),
|
|
}))
|
|
.mutation(async ({ input, ctx }) => {
|
|
const { id, ...updates } = input;
|
|
const existing = await ctx.db!
|
|
.select({ id: scheduledReports.id, createdBy: scheduledReports.createdBy })
|
|
.from(scheduledReports)
|
|
.where(eq(scheduledReports.id, id))
|
|
.limit(1);
|
|
const report = existing[0];
|
|
if (!report) {
|
|
throw new (await import('./router')).TRPCError({ code: 'NOT_FOUND', message: 'Scheduled report not found' });
|
|
}
|
|
if (report.createdBy !== ctx.userId) {
|
|
throw new (await import('./router')).TRPCError({ code: 'FORBIDDEN', message: 'Not the report owner' });
|
|
}
|
|
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,
|
|
createdBy: ctx.userId,
|
|
});
|
|
return cohort;
|
|
}),
|
|
|
|
addCohortMember: protectedProcedure
|
|
.input(z.object({
|
|
cohortId: z.number().int().positive(),
|
|
userId: z.number().int().positive(),
|
|
}))
|
|
.mutation(async ({ input, ctx }) => {
|
|
const existing = await ctx.db!
|
|
.select({ id: cohorts.id, createdBy: cohorts.createdBy })
|
|
.from(cohorts)
|
|
.where(eq(cohorts.id, input.cohortId))
|
|
.limit(1);
|
|
const cohort = existing[0];
|
|
if (!cohort) {
|
|
throw new (await import('./router')).TRPCError({ code: 'NOT_FOUND', message: 'Cohort not found' });
|
|
}
|
|
if (cohort.createdBy !== ctx.userId) {
|
|
throw new (await import('./router')).TRPCError({ code: 'FORBIDDEN', message: 'Not the cohort owner' });
|
|
}
|
|
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: protectedProcedure
|
|
.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: protectedProcedure
|
|
.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();
|
|
}),
|
|
};
|