Files
FrenoCorp/src/lib/analytics/slack-alerts.ts
Michael Freno b89575fb6e FRE-605: Implement Phase 4 Change Tracking & Merge Logic
- Create ChangeTracker class with full version history support
  - Document change recording with metadata
  - Snapshot creation and restoration
  - Change acceptance/rejection workflow
  - Change diff generation between snapshots
  - Event-based change notifications

- Implement MergeLogic with screenplay-specific rules
  - Server change application with conflict detection
  - Auto-resolution for non-overlapping edits
  - Scene-aware merge rules (same-scene vs different-scene)
  - Manual conflict resolution workflow
  - Merge validation

- Write comprehensive unit tests
  - Change recording and tracking tests
  - Snapshot management tests
  - Conflict resolution tests
  - Screenplay-specific merge rule tests

- Document implementation in analysis/fre605_change_tracking_implementation.md

Architecture: ChangeTracker integrates with Yjs document updates.
MergeLogic applies screenplay-specific rules for concurrent edits.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-25 02:14:54 -04:00

256 lines
6.3 KiB
TypeScript

import { eq, and, gte, desc } from "drizzle-orm";
import { alerts, alertRules } from "../../db/schema";
import type { DrizzleDB } from "../../db/config/migrations";
import type { NewAlert, Alert } from "../../db/schema";
import { checkKPIAgainstThreshold, type KPIKey } from "./kpi-service";
export interface SlackConfig {
webhookUrl: string;
defaultChannel?: string;
}
export interface AlertResult {
triggered: boolean;
alert?: Alert;
ruleName: string;
severity: "low" | "medium" | "high" | "critical";
}
export async function evaluateAlertRules(
db: DrizzleDB,
kpiKey: KPIKey,
currentValue: number
): Promise<AlertResult[]> {
const activeRules = await db
.select()
.from(alertRules)
.where(and(
eq(alertRules.kpiKey, kpiKey),
eq(alertRules.isActive, true)
));
const results: AlertResult[] = [];
for (const rule of activeRules) {
const triggered = isRuleTriggered(rule, currentValue);
if (!triggered) {
results.push({ triggered: false, ruleName: rule.name, severity: rule.severity });
continue;
}
const cooldownMs = rule.cooldownMinutes * 60 * 1000;
const cooldownCutoff = new Date(Date.now() - cooldownMs);
const recentAlert = await db
.select({ id: alerts.id })
.from(alerts)
.where(and(
eq(alerts.ruleId, rule.id),
gte(alerts.createdAt, cooldownCutoff)
))
.orderBy(desc(alerts.createdAt))
.limit(1);
if (recentAlert.length > 0) {
results.push({ triggered: false, ruleName: rule.name, severity: rule.severity });
continue;
}
const severity = mapSeverity(rule, currentValue, kpiKey);
const message = formatAlertMessage(rule, currentValue, kpiKey);
const newAlert: NewAlert = {
ruleId: rule.id,
kpiKey,
kpiValue: currentValue,
threshold: rule.threshold,
severity,
message,
wasSent: false,
};
const result = await db.insert(alerts).values(newAlert).returning();
const alert = result[0];
results.push({ triggered: true, alert, ruleName: rule.name, severity });
}
return results;
}
function isRuleTriggered(rule: typeof alertRules.$inferSelect, value: number): boolean {
switch (rule.condition) {
case "above":
return value > rule.threshold;
case "below":
return value < rule.threshold;
case "equals":
return value === rule.threshold;
case "increasing":
return value > rule.threshold;
case "decreasing":
return value < rule.threshold;
default:
return false;
}
}
function mapSeverity(
rule: typeof alertRules.$inferSelect,
_value: number,
kpiKey: KPIKey
): "low" | "medium" | "high" | "critical" {
const { severity: kpiSeverity } = checkKPIAgainstThreshold(kpiKey, _value);
if (kpiSeverity === "critical") return "critical";
if (kpiSeverity === "warning") return rule.severity === "critical" ? "high" : "medium";
return rule.severity;
}
export function formatAlertMessage(
rule: typeof alertRules.$inferSelect,
value: number,
kpiKey: KPIKey
): string {
const formattedValue = Number.isInteger(value) ? value.toString() : value.toFixed(2);
const formattedThreshold = Number.isInteger(rule.threshold)
? rule.threshold.toString()
: rule.threshold.toFixed(2);
return `${rule.name}: ${kpiKey} is ${formattedValue} (${rule.condition} ${formattedThreshold})`;
}
export async function sendSlackAlert(
config: SlackConfig,
alert: Alert,
overrideChannel?: string
): Promise<boolean> {
const channel = overrideChannel || config.defaultChannel || "#alerts";
const color = alertSeverityToSlackColor(alert.severity);
const blocks = [
{
type: "header",
text: {
type: "plain_text",
text: `🚨 KPI Alert: ${alert.kpi_key}`,
emoji: true,
},
},
{
type: "section",
fields: [
{
type: "mrkdwn",
text: `*Severity:*\n${alert.severity.toUpperCase()}`,
},
{
type: "mrkdwn",
text: `*Current Value:*\n${alert.kpi_value.toFixed(2)}`,
},
{
type: "mrkdwn",
text: `*Threshold:*\n${alert.threshold.toFixed(2)}`,
},
{
type: "mrkdwn",
text: `*Time:*\n${new Date(alert.created_at?.getTime() ?? Date.now()).toISOString()}`,
},
],
},
{
type: "section",
text: {
type: "mrkdwn",
text: alert.message,
},
},
];
const payload = {
channel,
blocks,
username: "FrenoCorp Alerts",
icon_emoji: ":warning:",
};
try {
const response = await fetch(config.webhookUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!response.ok) {
console.error(`Slack webhook error: ${response.status} ${response.statusText}`);
return false;
}
await markAlertSent(alert.id);
return true;
} catch (error) {
console.error("Failed to send Slack alert:", error);
return false;
}
}
async function markAlertSent(alertId: number): Promise<void> {
const db = await getDb();
if (db) {
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;
}
}
function alertSeverityToSlackColor(
severity: "low" | "medium" | "high" | "critical"
): string {
switch (severity) {
case "critical":
return "#FF0000";
case "high":
return "#FFA500";
case "medium":
return "#FFFF00";
case "low":
return "#00FF00";
default:
return "#808080";
}
}
export async function acknowledgeAlert(
db: DrizzleDB,
alertId: number,
acknowledgedBy: number
): Promise<Alert | null> {
const result = await db
.update(alerts)
.set({ acknowledgedBy, acknowledgedAt: new Date() })
.where(eq(alerts.id, alertId))
.returning();
return result[0] ?? null;
}
export async function getUnsentAlerts(db: DrizzleDB): Promise<Alert[]> {
return await db
.select()
.from(alerts)
.where(eq(alerts.wasSent, false))
.orderBy(alerts.createdAt);
}