package incidents import ( "context" "encoding/json" "fmt" "time" "github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" ) // Store handles incident data persistence type Store struct { pool *pgxpool.Pool } // NewStore creates a new incident store func NewStore(pool *pgxpool.Pool) *Store { return &Store{pool: pool} } // ============================================================================ // Incident CRUD Operations // ============================================================================ // CreateIncident creates a new incident func (s *Store) CreateIncident(ctx context.Context, incident *Incident) error { incident.ID = uuid.New() incident.CreatedAt = time.Now().UTC() incident.UpdatedAt = incident.CreatedAt if incident.Status == "" { incident.Status = IncidentStatusDetected } if incident.AffectedDataCategories == nil { incident.AffectedDataCategories = []string{} } if incident.AffectedSystems == nil { incident.AffectedSystems = []string{} } if incident.Timeline == nil { incident.Timeline = []TimelineEntry{} } affectedDataCategories, _ := json.Marshal(incident.AffectedDataCategories) affectedSystems, _ := json.Marshal(incident.AffectedSystems) riskAssessment, _ := json.Marshal(incident.RiskAssessment) authorityNotification, _ := json.Marshal(incident.AuthorityNotification) dataSubjectNotification, _ := json.Marshal(incident.DataSubjectNotification) timeline, _ := json.Marshal(incident.Timeline) _, err := s.pool.Exec(ctx, ` INSERT INTO incident_incidents ( id, tenant_id, title, description, category, status, severity, detected_at, reported_by, affected_data_categories, affected_data_subject_count, affected_systems, risk_assessment, authority_notification, data_subject_notification, root_cause, lessons_learned, timeline, created_at, updated_at, closed_at ) VALUES ( $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21 ) `, incident.ID, incident.TenantID, incident.Title, incident.Description, string(incident.Category), string(incident.Status), string(incident.Severity), incident.DetectedAt, incident.ReportedBy, affectedDataCategories, incident.AffectedDataSubjectCount, affectedSystems, riskAssessment, authorityNotification, dataSubjectNotification, incident.RootCause, incident.LessonsLearned, timeline, incident.CreatedAt, incident.UpdatedAt, incident.ClosedAt, ) return err } // GetIncident retrieves an incident by ID func (s *Store) GetIncident(ctx context.Context, id uuid.UUID) (*Incident, error) { var incident Incident var category, status, severity string var affectedDataCategories, affectedSystems []byte var riskAssessment, authorityNotification, dataSubjectNotification []byte var timeline []byte err := s.pool.QueryRow(ctx, ` SELECT id, tenant_id, title, description, category, status, severity, detected_at, reported_by, affected_data_categories, affected_data_subject_count, affected_systems, risk_assessment, authority_notification, data_subject_notification, root_cause, lessons_learned, timeline, created_at, updated_at, closed_at FROM incident_incidents WHERE id = $1 `, id).Scan( &incident.ID, &incident.TenantID, &incident.Title, &incident.Description, &category, &status, &severity, &incident.DetectedAt, &incident.ReportedBy, &affectedDataCategories, &incident.AffectedDataSubjectCount, &affectedSystems, &riskAssessment, &authorityNotification, &dataSubjectNotification, &incident.RootCause, &incident.LessonsLearned, &timeline, &incident.CreatedAt, &incident.UpdatedAt, &incident.ClosedAt, ) if err == pgx.ErrNoRows { return nil, nil } if err != nil { return nil, err } incident.Category = IncidentCategory(category) incident.Status = IncidentStatus(status) incident.Severity = IncidentSeverity(severity) json.Unmarshal(affectedDataCategories, &incident.AffectedDataCategories) json.Unmarshal(affectedSystems, &incident.AffectedSystems) json.Unmarshal(riskAssessment, &incident.RiskAssessment) json.Unmarshal(authorityNotification, &incident.AuthorityNotification) json.Unmarshal(dataSubjectNotification, &incident.DataSubjectNotification) json.Unmarshal(timeline, &incident.Timeline) if incident.AffectedDataCategories == nil { incident.AffectedDataCategories = []string{} } if incident.AffectedSystems == nil { incident.AffectedSystems = []string{} } if incident.Timeline == nil { incident.Timeline = []TimelineEntry{} } return &incident, nil } // ListIncidents lists incidents for a tenant with optional filters func (s *Store) ListIncidents(ctx context.Context, tenantID uuid.UUID, filters *IncidentFilters) ([]Incident, int, error) { // Count query countQuery := "SELECT COUNT(*) FROM incident_incidents WHERE tenant_id = $1" countArgs := []interface{}{tenantID} countArgIdx := 2 if filters != nil { if filters.Status != "" { countQuery += fmt.Sprintf(" AND status = $%d", countArgIdx) countArgs = append(countArgs, string(filters.Status)) countArgIdx++ } if filters.Severity != "" { countQuery += fmt.Sprintf(" AND severity = $%d", countArgIdx) countArgs = append(countArgs, string(filters.Severity)) countArgIdx++ } if filters.Category != "" { countQuery += fmt.Sprintf(" AND category = $%d", countArgIdx) countArgs = append(countArgs, string(filters.Category)) countArgIdx++ } } var total int err := s.pool.QueryRow(ctx, countQuery, countArgs...).Scan(&total) if err != nil { return nil, 0, err } // Data query query := ` SELECT id, tenant_id, title, description, category, status, severity, detected_at, reported_by, affected_data_categories, affected_data_subject_count, affected_systems, risk_assessment, authority_notification, data_subject_notification, root_cause, lessons_learned, timeline, created_at, updated_at, closed_at FROM incident_incidents WHERE tenant_id = $1` args := []interface{}{tenantID} argIdx := 2 if filters != nil { if filters.Status != "" { query += fmt.Sprintf(" AND status = $%d", argIdx) args = append(args, string(filters.Status)) argIdx++ } if filters.Severity != "" { query += fmt.Sprintf(" AND severity = $%d", argIdx) args = append(args, string(filters.Severity)) argIdx++ } if filters.Category != "" { query += fmt.Sprintf(" AND category = $%d", argIdx) args = append(args, string(filters.Category)) argIdx++ } } query += " ORDER BY detected_at DESC" if filters != nil && filters.Limit > 0 { query += fmt.Sprintf(" LIMIT $%d", argIdx) args = append(args, filters.Limit) argIdx++ if filters.Offset > 0 { query += fmt.Sprintf(" OFFSET $%d", argIdx) args = append(args, filters.Offset) argIdx++ } } rows, err := s.pool.Query(ctx, query, args...) if err != nil { return nil, 0, err } defer rows.Close() var incidents []Incident for rows.Next() { var incident Incident var category, status, severity string var affectedDataCategories, affectedSystems []byte var riskAssessment, authorityNotification, dataSubjectNotification []byte var timeline []byte err := rows.Scan( &incident.ID, &incident.TenantID, &incident.Title, &incident.Description, &category, &status, &severity, &incident.DetectedAt, &incident.ReportedBy, &affectedDataCategories, &incident.AffectedDataSubjectCount, &affectedSystems, &riskAssessment, &authorityNotification, &dataSubjectNotification, &incident.RootCause, &incident.LessonsLearned, &timeline, &incident.CreatedAt, &incident.UpdatedAt, &incident.ClosedAt, ) if err != nil { return nil, 0, err } incident.Category = IncidentCategory(category) incident.Status = IncidentStatus(status) incident.Severity = IncidentSeverity(severity) json.Unmarshal(affectedDataCategories, &incident.AffectedDataCategories) json.Unmarshal(affectedSystems, &incident.AffectedSystems) json.Unmarshal(riskAssessment, &incident.RiskAssessment) json.Unmarshal(authorityNotification, &incident.AuthorityNotification) json.Unmarshal(dataSubjectNotification, &incident.DataSubjectNotification) json.Unmarshal(timeline, &incident.Timeline) if incident.AffectedDataCategories == nil { incident.AffectedDataCategories = []string{} } if incident.AffectedSystems == nil { incident.AffectedSystems = []string{} } if incident.Timeline == nil { incident.Timeline = []TimelineEntry{} } incidents = append(incidents, incident) } return incidents, total, nil } // UpdateIncident updates an incident func (s *Store) UpdateIncident(ctx context.Context, incident *Incident) error { incident.UpdatedAt = time.Now().UTC() affectedDataCategories, _ := json.Marshal(incident.AffectedDataCategories) affectedSystems, _ := json.Marshal(incident.AffectedSystems) _, err := s.pool.Exec(ctx, ` UPDATE incident_incidents SET title = $2, description = $3, category = $4, status = $5, severity = $6, affected_data_categories = $7, affected_data_subject_count = $8, affected_systems = $9, root_cause = $10, lessons_learned = $11, updated_at = $12 WHERE id = $1 `, incident.ID, incident.Title, incident.Description, string(incident.Category), string(incident.Status), string(incident.Severity), affectedDataCategories, incident.AffectedDataSubjectCount, affectedSystems, incident.RootCause, incident.LessonsLearned, incident.UpdatedAt, ) return err } // DeleteIncident deletes an incident and its related measures (cascade handled by FK) func (s *Store) DeleteIncident(ctx context.Context, id uuid.UUID) error { _, err := s.pool.Exec(ctx, "DELETE FROM incident_incidents WHERE id = $1", id) return err } // ============================================================================ // Risk Assessment Operations // ============================================================================ // UpdateRiskAssessment updates the risk assessment for an incident func (s *Store) UpdateRiskAssessment(ctx context.Context, incidentID uuid.UUID, assessment *RiskAssessment) error { assessmentJSON, _ := json.Marshal(assessment) _, err := s.pool.Exec(ctx, ` UPDATE incident_incidents SET risk_assessment = $2, updated_at = NOW() WHERE id = $1 `, incidentID, assessmentJSON) return err } // ============================================================================ // Notification Operations // ============================================================================ // UpdateAuthorityNotification updates the authority notification for an incident func (s *Store) UpdateAuthorityNotification(ctx context.Context, incidentID uuid.UUID, notification *AuthorityNotification) error { notificationJSON, _ := json.Marshal(notification) _, err := s.pool.Exec(ctx, ` UPDATE incident_incidents SET authority_notification = $2, updated_at = NOW() WHERE id = $1 `, incidentID, notificationJSON) return err } // UpdateDataSubjectNotification updates the data subject notification for an incident func (s *Store) UpdateDataSubjectNotification(ctx context.Context, incidentID uuid.UUID, notification *DataSubjectNotification) error { notificationJSON, _ := json.Marshal(notification) _, err := s.pool.Exec(ctx, ` UPDATE incident_incidents SET data_subject_notification = $2, updated_at = NOW() WHERE id = $1 `, incidentID, notificationJSON) return err } // ============================================================================ // Measure Operations // ============================================================================ // AddMeasure adds a corrective measure to an incident func (s *Store) AddMeasure(ctx context.Context, measure *IncidentMeasure) error { measure.ID = uuid.New() measure.CreatedAt = time.Now().UTC() if measure.Status == "" { measure.Status = MeasureStatusPlanned } _, err := s.pool.Exec(ctx, ` INSERT INTO incident_measures ( id, incident_id, title, description, measure_type, status, responsible, due_date, completed_at, created_at ) VALUES ( $1, $2, $3, $4, $5, $6, $7, $8, $9, $10 ) `, measure.ID, measure.IncidentID, measure.Title, measure.Description, string(measure.MeasureType), string(measure.Status), measure.Responsible, measure.DueDate, measure.CompletedAt, measure.CreatedAt, ) return err } // ListMeasures lists all measures for an incident func (s *Store) ListMeasures(ctx context.Context, incidentID uuid.UUID) ([]IncidentMeasure, error) { rows, err := s.pool.Query(ctx, ` SELECT id, incident_id, title, description, measure_type, status, responsible, due_date, completed_at, created_at FROM incident_measures WHERE incident_id = $1 ORDER BY created_at ASC `, incidentID) if err != nil { return nil, err } defer rows.Close() var measures []IncidentMeasure for rows.Next() { var m IncidentMeasure var measureType, status string err := rows.Scan( &m.ID, &m.IncidentID, &m.Title, &m.Description, &measureType, &status, &m.Responsible, &m.DueDate, &m.CompletedAt, &m.CreatedAt, ) if err != nil { return nil, err } m.MeasureType = MeasureType(measureType) m.Status = MeasureStatus(status) measures = append(measures, m) } return measures, nil } // UpdateMeasure updates an existing measure func (s *Store) UpdateMeasure(ctx context.Context, measure *IncidentMeasure) error { _, err := s.pool.Exec(ctx, ` UPDATE incident_measures SET title = $2, description = $3, measure_type = $4, status = $5, responsible = $6, due_date = $7, completed_at = $8 WHERE id = $1 `, measure.ID, measure.Title, measure.Description, string(measure.MeasureType), string(measure.Status), measure.Responsible, measure.DueDate, measure.CompletedAt, ) return err } // CompleteMeasure marks a measure as completed func (s *Store) CompleteMeasure(ctx context.Context, id uuid.UUID) error { now := time.Now().UTC() _, err := s.pool.Exec(ctx, ` UPDATE incident_measures SET status = $2, completed_at = $3 WHERE id = $1 `, id, string(MeasureStatusCompleted), now) return err } // ============================================================================ // Timeline Operations // ============================================================================ // AddTimelineEntry appends a timeline entry to the incident's JSONB timeline array func (s *Store) AddTimelineEntry(ctx context.Context, incidentID uuid.UUID, entry TimelineEntry) error { entryJSON, err := json.Marshal(entry) if err != nil { return err } // Use the || operator to append to the JSONB array _, err = s.pool.Exec(ctx, ` UPDATE incident_incidents SET timeline = COALESCE(timeline, '[]'::jsonb) || $2::jsonb, updated_at = NOW() WHERE id = $1 `, incidentID, string(entryJSON)) return err } // ============================================================================ // Close Incident // ============================================================================ // CloseIncident closes an incident with root cause and lessons learned func (s *Store) CloseIncident(ctx context.Context, id uuid.UUID, rootCause, lessonsLearned string) error { now := time.Now().UTC() _, err := s.pool.Exec(ctx, ` UPDATE incident_incidents SET status = $2, root_cause = $3, lessons_learned = $4, closed_at = $5, updated_at = $5 WHERE id = $1 `, id, string(IncidentStatusClosed), rootCause, lessonsLearned, now) return err } // ============================================================================ // Statistics // ============================================================================ // GetStatistics returns aggregated incident statistics for a tenant func (s *Store) GetStatistics(ctx context.Context, tenantID uuid.UUID) (*IncidentStatistics, error) { stats := &IncidentStatistics{ ByStatus: make(map[string]int), BySeverity: make(map[string]int), ByCategory: make(map[string]int), } // Total incidents s.pool.QueryRow(ctx, "SELECT COUNT(*) FROM incident_incidents WHERE tenant_id = $1", tenantID).Scan(&stats.TotalIncidents) // Open incidents (not closed) s.pool.QueryRow(ctx, "SELECT COUNT(*) FROM incident_incidents WHERE tenant_id = $1 AND status != 'closed'", tenantID).Scan(&stats.OpenIncidents) // By status rows, err := s.pool.Query(ctx, "SELECT status, COUNT(*) FROM incident_incidents WHERE tenant_id = $1 GROUP BY status", tenantID) if err == nil { defer rows.Close() for rows.Next() { var status string var count int rows.Scan(&status, &count) stats.ByStatus[status] = count } } // By severity rows, err = s.pool.Query(ctx, "SELECT severity, COUNT(*) FROM incident_incidents WHERE tenant_id = $1 GROUP BY severity", tenantID) if err == nil { defer rows.Close() for rows.Next() { var severity string var count int rows.Scan(&severity, &count) stats.BySeverity[severity] = count } } // By category rows, err = s.pool.Query(ctx, "SELECT category, COUNT(*) FROM incident_incidents WHERE tenant_id = $1 GROUP BY category", tenantID) if err == nil { defer rows.Close() for rows.Next() { var category string var count int rows.Scan(&category, &count) stats.ByCategory[category] = count } } // Notifications pending s.pool.QueryRow(ctx, ` SELECT COUNT(*) FROM incident_incidents WHERE tenant_id = $1 AND (authority_notification->>'status' = 'pending' OR data_subject_notification->>'status' = 'pending') `, tenantID).Scan(&stats.NotificationsPending) // Average resolution hours (for closed incidents) s.pool.QueryRow(ctx, ` SELECT COALESCE(AVG(EXTRACT(EPOCH FROM (closed_at - detected_at)) / 3600), 0) FROM incident_incidents WHERE tenant_id = $1 AND status = 'closed' AND closed_at IS NOT NULL `, tenantID).Scan(&stats.AvgResolutionHours) return stats, nil }