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>
This commit is contained in:
255
src/lib/analytics/slack-alerts.ts
Normal file
255
src/lib/analytics/slack-alerts.ts
Normal file
@@ -0,0 +1,255 @@
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user