From 1e4bc99fd17afc6eed3f32b1a86487baddd91779 Mon Sep 17 00:00:00 2001 From: admin Date: Tue, 24 Feb 2026 11:33:20 -0500 Subject: [PATCH] feat(01-01): add HealthCheckModel and AlertEventModel with barrel exports - HealthCheckModel: typed interfaces (ServiceHealthCheck, CreateHealthCheckData), static methods create/findLatestByService/findAll/deleteOlderThan, input validation, getSupabaseServiceClient() per-method, Winston logging - AlertEventModel: typed interfaces (AlertEvent, CreateAlertEventData), static methods create/findActive/acknowledge/resolve/findRecentByService/deleteOlderThan, input validation, PGRST116 handled as null, Winston logging - Update models/index.ts to re-export both models and their types - Strict TypeScript: Record for JSONB fields, no any types --- backend/src/models/AlertEventModel.ts | 343 +++++++++++++++++++++++++ backend/src/models/HealthCheckModel.ts | 219 ++++++++++++++++ backend/src/models/index.ts | 6 + 3 files changed, 568 insertions(+) create mode 100644 backend/src/models/AlertEventModel.ts create mode 100644 backend/src/models/HealthCheckModel.ts diff --git a/backend/src/models/AlertEventModel.ts b/backend/src/models/AlertEventModel.ts new file mode 100644 index 0000000..0042bd6 --- /dev/null +++ b/backend/src/models/AlertEventModel.ts @@ -0,0 +1,343 @@ +import { getSupabaseServiceClient } from '../config/supabase'; +import { logger } from '../utils/logger'; + +// ============================================================================= +// Types +// ============================================================================= + +export interface AlertEvent { + id: string; + service_name: string; + alert_type: 'service_down' | 'service_degraded' | 'recovery'; + status: 'active' | 'acknowledged' | 'resolved'; + message: string | null; + details: Record | null; + created_at: string; + acknowledged_at: string | null; + resolved_at: string | null; +} + +export interface CreateAlertEventData { + service_name: string; + alert_type: 'service_down' | 'service_degraded' | 'recovery'; + status?: 'active' | 'acknowledged' | 'resolved'; + message?: string; + details?: Record; +} + +// ============================================================================= +// Model +// ============================================================================= + +export class AlertEventModel { + /** + * Create a new alert event. + * Defaults status to 'active' if not provided. + * Validates input before writing to the database. + */ + static async create(data: CreateAlertEventData): Promise { + const { + service_name, + alert_type, + status = 'active', + message, + details, + } = data; + + if (!service_name || service_name.trim() === '') { + throw new Error('AlertEventModel.create: service_name must be a non-empty string'); + } + + const validAlertTypes: Array<'service_down' | 'service_degraded' | 'recovery'> = [ + 'service_down', + 'service_degraded', + 'recovery', + ]; + if (!validAlertTypes.includes(alert_type)) { + throw new Error(`AlertEventModel.create: alert_type must be one of ${validAlertTypes.join(', ')}, got "${alert_type}"`); + } + + const validStatuses: Array<'active' | 'acknowledged' | 'resolved'> = [ + 'active', + 'acknowledged', + 'resolved', + ]; + if (!validStatuses.includes(status)) { + throw new Error(`AlertEventModel.create: status must be one of ${validStatuses.join(', ')}, got "${status}"`); + } + + try { + const supabase = getSupabaseServiceClient(); + + const { data: record, error } = await supabase + .from('alert_events') + .insert({ + service_name: service_name.trim(), + alert_type, + status, + message: message ?? null, + details: details ?? null, + }) + .select() + .single(); + + if (error) { + logger.error('AlertEventModel.create: Supabase insert failed', { + error: error.message, + code: error.code, + details: error.details, + service_name, + alert_type, + status, + }); + throw new Error(`AlertEventModel.create: failed to insert alert event — ${error.message}`); + } + + if (!record) { + throw new Error('AlertEventModel.create: insert succeeded but no data returned'); + } + + logger.info('AlertEventModel.create: alert event recorded', { + id: record.id, + service_name: record.service_name, + alert_type: record.alert_type, + status: record.status, + }); + + return record as AlertEvent; + } catch (error) { + if (error instanceof Error && error.message.startsWith('AlertEventModel.create:')) { + throw error; + } + throw new Error(`AlertEventModel.create: unexpected error — ${error instanceof Error ? error.message : String(error)}`); + } + } + + /** + * Get active (unresolved, unacknowledged) alerts. + * Optional service_name filter. Ordered by created_at DESC. + */ + static async findActive(serviceName?: string): Promise { + try { + const supabase = getSupabaseServiceClient(); + + let query = supabase + .from('alert_events') + .select('*') + .eq('status', 'active') + .order('created_at', { ascending: false }); + + if (serviceName) { + query = query.eq('service_name', serviceName); + } + + const { data, error } = await query; + + if (error) { + logger.error('AlertEventModel.findActive: query failed', { + error: error.message, + code: error.code, + serviceName, + }); + throw new Error(`AlertEventModel.findActive: query failed — ${error.message}`); + } + + return (data ?? []) as AlertEvent[]; + } catch (error) { + if (error instanceof Error && error.message.startsWith('AlertEventModel.findActive:')) { + throw error; + } + throw new Error(`AlertEventModel.findActive: unexpected error — ${error instanceof Error ? error.message : String(error)}`); + } + } + + /** + * Acknowledge an alert event. + * Sets status to 'acknowledged' and records acknowledged_at timestamp. + * Returns the updated row. + */ + static async acknowledge(id: string): Promise { + try { + const supabase = getSupabaseServiceClient(); + + const { data: record, error } = await supabase + .from('alert_events') + .update({ + status: 'acknowledged', + acknowledged_at: new Date().toISOString(), + }) + .eq('id', id) + .select() + .single(); + + if (error) { + if (error.code === 'PGRST116') { + throw new Error(`AlertEventModel.acknowledge: alert event not found — id="${id}"`); + } + logger.error('AlertEventModel.acknowledge: update failed', { + error: error.message, + code: error.code, + id, + }); + throw new Error(`AlertEventModel.acknowledge: update failed — ${error.message}`); + } + + if (!record) { + throw new Error(`AlertEventModel.acknowledge: no data returned for id="${id}"`); + } + + logger.info('AlertEventModel.acknowledge: alert acknowledged', { id, service_name: record.service_name }); + + return record as AlertEvent; + } catch (error) { + if (error instanceof Error && error.message.startsWith('AlertEventModel.acknowledge:')) { + throw error; + } + throw new Error(`AlertEventModel.acknowledge: unexpected error — ${error instanceof Error ? error.message : String(error)}`); + } + } + + /** + * Resolve an alert event. + * Sets status to 'resolved' and records resolved_at timestamp. + * Returns the updated row. + */ + static async resolve(id: string): Promise { + try { + const supabase = getSupabaseServiceClient(); + + const { data: record, error } = await supabase + .from('alert_events') + .update({ + status: 'resolved', + resolved_at: new Date().toISOString(), + }) + .eq('id', id) + .select() + .single(); + + if (error) { + if (error.code === 'PGRST116') { + throw new Error(`AlertEventModel.resolve: alert event not found — id="${id}"`); + } + logger.error('AlertEventModel.resolve: update failed', { + error: error.message, + code: error.code, + id, + }); + throw new Error(`AlertEventModel.resolve: update failed — ${error.message}`); + } + + if (!record) { + throw new Error(`AlertEventModel.resolve: no data returned for id="${id}"`); + } + + logger.info('AlertEventModel.resolve: alert resolved', { id, service_name: record.service_name }); + + return record as AlertEvent; + } catch (error) { + if (error instanceof Error && error.message.startsWith('AlertEventModel.resolve:')) { + throw error; + } + throw new Error(`AlertEventModel.resolve: unexpected error — ${error instanceof Error ? error.message : String(error)}`); + } + } + + /** + * Find the most recent alert of a given type for a service within a time window. + * Used by the Phase 2 alert service for deduplication — prevents repeat alerts + * for the same condition within a cooldown period. + * Returns null if no matching alert found within the window. + */ + static async findRecentByService( + serviceName: string, + alertType: string, + withinMinutes: number + ): Promise { + try { + const supabase = getSupabaseServiceClient(); + + const cutoff = new Date(); + cutoff.setMinutes(cutoff.getMinutes() - withinMinutes); + + const { data, error } = await supabase + .from('alert_events') + .select('*') + .eq('service_name', serviceName) + .eq('alert_type', alertType) + .gte('created_at', cutoff.toISOString()) + .order('created_at', { ascending: false }) + .limit(1) + .single(); + + if (error) { + if (error.code === 'PGRST116') { + return null; // No matching alert within the window + } + logger.error('AlertEventModel.findRecentByService: query failed', { + error: error.message, + code: error.code, + serviceName, + alertType, + withinMinutes, + }); + throw new Error(`AlertEventModel.findRecentByService: query failed — ${error.message}`); + } + + return data as AlertEvent; + } catch (error) { + if (error instanceof Error && error.message.startsWith('AlertEventModel.findRecentByService:')) { + throw error; + } + throw new Error(`AlertEventModel.findRecentByService: unexpected error — ${error instanceof Error ? error.message : String(error)}`); + } + } + + /** + * Delete alert event records older than the specified number of days. + * Used by the Phase 2 scheduler for 30-day retention enforcement. + * Returns the count of deleted rows. + */ + static async deleteOlderThan(days: number): Promise { + if (days <= 0) { + throw new Error('AlertEventModel.deleteOlderThan: days must be a positive integer'); + } + + try { + const supabase = getSupabaseServiceClient(); + + const cutoff = new Date(); + cutoff.setDate(cutoff.getDate() - days); + + const { data, error } = await supabase + .from('alert_events') + .delete() + .lt('created_at', cutoff.toISOString()) + .select('id'); + + if (error) { + logger.error('AlertEventModel.deleteOlderThan: delete failed', { + error: error.message, + code: error.code, + days, + }); + throw new Error(`AlertEventModel.deleteOlderThan: delete failed — ${error.message}`); + } + + const deletedCount = data?.length ?? 0; + + logger.info('AlertEventModel.deleteOlderThan: retention cleanup complete', { + days, + deletedCount, + cutoff: cutoff.toISOString(), + }); + + return deletedCount; + } catch (error) { + if (error instanceof Error && error.message.startsWith('AlertEventModel.deleteOlderThan:')) { + throw error; + } + throw new Error(`AlertEventModel.deleteOlderThan: unexpected error — ${error instanceof Error ? error.message : String(error)}`); + } + } +} diff --git a/backend/src/models/HealthCheckModel.ts b/backend/src/models/HealthCheckModel.ts new file mode 100644 index 0000000..c1b80a3 --- /dev/null +++ b/backend/src/models/HealthCheckModel.ts @@ -0,0 +1,219 @@ +import { getSupabaseServiceClient } from '../config/supabase'; +import { logger } from '../utils/logger'; + +// ============================================================================= +// Types +// ============================================================================= + +export interface ServiceHealthCheck { + id: string; + service_name: string; + status: 'healthy' | 'degraded' | 'down'; + latency_ms: number | null; + checked_at: string; + error_message: string | null; + probe_details: Record | null; + created_at: string; +} + +export interface CreateHealthCheckData { + service_name: string; + status: 'healthy' | 'degraded' | 'down'; + latency_ms?: number; + error_message?: string; + probe_details?: Record; +} + +// ============================================================================= +// Model +// ============================================================================= + +export class HealthCheckModel { + /** + * Create a new health check record. + * Validates input before writing to the database. + */ + static async create(data: CreateHealthCheckData): Promise { + const { service_name, status, latency_ms, error_message, probe_details } = data; + + if (!service_name || service_name.trim() === '') { + throw new Error('HealthCheckModel.create: service_name must be a non-empty string'); + } + + const validStatuses: Array<'healthy' | 'degraded' | 'down'> = ['healthy', 'degraded', 'down']; + if (!validStatuses.includes(status)) { + throw new Error(`HealthCheckModel.create: status must be one of ${validStatuses.join(', ')}, got "${status}"`); + } + + try { + const supabase = getSupabaseServiceClient(); + + const { data: record, error } = await supabase + .from('service_health_checks') + .insert({ + service_name: service_name.trim(), + status, + latency_ms: latency_ms ?? null, + error_message: error_message ?? null, + probe_details: probe_details ?? null, + }) + .select() + .single(); + + if (error) { + logger.error('HealthCheckModel.create: Supabase insert failed', { + error: error.message, + code: error.code, + details: error.details, + service_name, + status, + }); + throw new Error(`HealthCheckModel.create: failed to insert health check — ${error.message}`); + } + + if (!record) { + throw new Error('HealthCheckModel.create: insert succeeded but no data returned'); + } + + logger.info('HealthCheckModel.create: health check recorded', { + id: record.id, + service_name: record.service_name, + status: record.status, + }); + + return record as ServiceHealthCheck; + } catch (error) { + if (error instanceof Error && error.message.startsWith('HealthCheckModel.create:')) { + throw error; + } + throw new Error(`HealthCheckModel.create: unexpected error — ${error instanceof Error ? error.message : String(error)}`); + } + } + + /** + * Get the most recent health check for a given service. + * Ordered by checked_at DESC (probe time, not row creation time). + * Returns null if no record found. + */ + static async findLatestByService(serviceName: string): Promise { + try { + const supabase = getSupabaseServiceClient(); + + const { data, error } = await supabase + .from('service_health_checks') + .select('*') + .eq('service_name', serviceName) + .order('checked_at', { ascending: false }) + .limit(1) + .single(); + + if (error) { + if (error.code === 'PGRST116') { + return null; // No rows returned — not an error + } + logger.error('HealthCheckModel.findLatestByService: query failed', { + error: error.message, + code: error.code, + serviceName, + }); + throw new Error(`HealthCheckModel.findLatestByService: query failed — ${error.message}`); + } + + return data as ServiceHealthCheck; + } catch (error) { + if (error instanceof Error && error.message.startsWith('HealthCheckModel.findLatestByService:')) { + throw error; + } + throw new Error(`HealthCheckModel.findLatestByService: unexpected error — ${error instanceof Error ? error.message : String(error)}`); + } + } + + /** + * List health checks with optional filtering by service name. + * Ordered by created_at DESC. Defaults to limit 100. + */ + static async findAll(options?: { limit?: number; serviceName?: string }): Promise { + const limit = options?.limit ?? 100; + + try { + const supabase = getSupabaseServiceClient(); + + let query = supabase + .from('service_health_checks') + .select('*') + .order('created_at', { ascending: false }) + .limit(limit); + + if (options?.serviceName) { + query = query.eq('service_name', options.serviceName); + } + + const { data, error } = await query; + + if (error) { + logger.error('HealthCheckModel.findAll: query failed', { + error: error.message, + code: error.code, + options, + }); + throw new Error(`HealthCheckModel.findAll: query failed — ${error.message}`); + } + + return (data ?? []) as ServiceHealthCheck[]; + } catch (error) { + if (error instanceof Error && error.message.startsWith('HealthCheckModel.findAll:')) { + throw error; + } + throw new Error(`HealthCheckModel.findAll: unexpected error — ${error instanceof Error ? error.message : String(error)}`); + } + } + + /** + * Delete health check records older than the specified number of days. + * Used by the Phase 2 scheduler for 30-day retention enforcement. + * Returns the count of deleted rows. + */ + static async deleteOlderThan(days: number): Promise { + if (days <= 0) { + throw new Error('HealthCheckModel.deleteOlderThan: days must be a positive integer'); + } + + try { + const supabase = getSupabaseServiceClient(); + + // Supabase does not support arithmetic in filters directly — compute cutoff in JS + const cutoff = new Date(); + cutoff.setDate(cutoff.getDate() - days); + + const { data, error } = await supabase + .from('service_health_checks') + .delete() + .lt('created_at', cutoff.toISOString()) + .select('id'); + + if (error) { + logger.error('HealthCheckModel.deleteOlderThan: delete failed', { + error: error.message, + code: error.code, + days, + }); + throw new Error(`HealthCheckModel.deleteOlderThan: delete failed — ${error.message}`); + } + + const deletedCount = data?.length ?? 0; + + logger.info('HealthCheckModel.deleteOlderThan: retention cleanup complete', { + days, + deletedCount, + cutoff: cutoff.toISOString(), + }); + + return deletedCount; + } catch (error) { + if (error instanceof Error && error.message.startsWith('HealthCheckModel.deleteOlderThan:')) { + throw error; + } + throw new Error(`HealthCheckModel.deleteOlderThan: unexpected error — ${error instanceof Error ? error.message : String(error)}`); + } + } +} diff --git a/backend/src/models/index.ts b/backend/src/models/index.ts index e727cc8..4882998 100644 --- a/backend/src/models/index.ts +++ b/backend/src/models/index.ts @@ -4,6 +4,12 @@ export { DocumentModel } from './DocumentModel'; export { DocumentFeedbackModel } from './DocumentFeedbackModel'; export { DocumentVersionModel } from './DocumentVersionModel'; export { ProcessingJobModel } from './ProcessingJobModel'; +export { HealthCheckModel } from './HealthCheckModel'; +export { AlertEventModel } from './AlertEventModel'; + +// Export monitoring model types +export type { ServiceHealthCheck, CreateHealthCheckData } from './HealthCheckModel'; +export type { AlertEvent, CreateAlertEventData } from './AlertEventModel'; // Export types export * from './types';