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<string, unknown> for JSONB fields, no any types
This commit is contained in:
343
backend/src/models/AlertEventModel.ts
Normal file
343
backend/src/models/AlertEventModel.ts
Normal file
@@ -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<string, unknown> | 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<string, unknown>;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 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<AlertEvent> {
|
||||
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<AlertEvent[]> {
|
||||
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<AlertEvent> {
|
||||
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<AlertEvent> {
|
||||
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<AlertEvent | null> {
|
||||
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<number> {
|
||||
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)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
219
backend/src/models/HealthCheckModel.ts
Normal file
219
backend/src/models/HealthCheckModel.ts
Normal file
@@ -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<string, unknown> | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface CreateHealthCheckData {
|
||||
service_name: string;
|
||||
status: 'healthy' | 'degraded' | 'down';
|
||||
latency_ms?: number;
|
||||
error_message?: string;
|
||||
probe_details?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Model
|
||||
// =============================================================================
|
||||
|
||||
export class HealthCheckModel {
|
||||
/**
|
||||
* Create a new health check record.
|
||||
* Validates input before writing to the database.
|
||||
*/
|
||||
static async create(data: CreateHealthCheckData): Promise<ServiceHealthCheck> {
|
||||
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<ServiceHealthCheck | null> {
|
||||
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<ServiceHealthCheck[]> {
|
||||
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<number> {
|
||||
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)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user