feat: Add Academy, Whistleblower, Incidents, Vendor, DSB, SSO, Reporting, Multi-Tenant and Industry backends

Go handlers, models, stores and migrations for all SDK modules.
Updates developer portal navigation and BYOEH page.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Benjamin Boenisch
2026-02-13 21:11:27 +01:00
parent 364d2c69ff
commit 504dd3591b
40 changed files with 13105 additions and 7 deletions

View File

@@ -0,0 +1,305 @@
package incidents
import (
"time"
"github.com/google/uuid"
)
// ============================================================================
// Constants / Enums
// ============================================================================
// IncidentCategory represents the category of a security/data breach incident
type IncidentCategory string
const (
IncidentCategoryDataBreach IncidentCategory = "data_breach"
IncidentCategoryUnauthorizedAccess IncidentCategory = "unauthorized_access"
IncidentCategoryDataLoss IncidentCategory = "data_loss"
IncidentCategorySystemCompromise IncidentCategory = "system_compromise"
IncidentCategoryPhishing IncidentCategory = "phishing"
IncidentCategoryRansomware IncidentCategory = "ransomware"
IncidentCategoryInsiderThreat IncidentCategory = "insider_threat"
IncidentCategoryPhysicalBreach IncidentCategory = "physical_breach"
IncidentCategoryOther IncidentCategory = "other"
)
// IncidentStatus represents the status of an incident through its lifecycle
type IncidentStatus string
const (
IncidentStatusDetected IncidentStatus = "detected"
IncidentStatusAssessment IncidentStatus = "assessment"
IncidentStatusContainment IncidentStatus = "containment"
IncidentStatusNotificationRequired IncidentStatus = "notification_required"
IncidentStatusNotificationSent IncidentStatus = "notification_sent"
IncidentStatusRemediation IncidentStatus = "remediation"
IncidentStatusClosed IncidentStatus = "closed"
)
// IncidentSeverity represents the severity level of an incident
type IncidentSeverity string
const (
IncidentSeverityCritical IncidentSeverity = "critical"
IncidentSeverityHigh IncidentSeverity = "high"
IncidentSeverityMedium IncidentSeverity = "medium"
IncidentSeverityLow IncidentSeverity = "low"
)
// MeasureType represents the type of corrective measure
type MeasureType string
const (
MeasureTypeImmediate MeasureType = "immediate"
MeasureTypeLongTerm MeasureType = "long_term"
)
// MeasureStatus represents the status of a corrective measure
type MeasureStatus string
const (
MeasureStatusPlanned MeasureStatus = "planned"
MeasureStatusInProgress MeasureStatus = "in_progress"
MeasureStatusCompleted MeasureStatus = "completed"
)
// NotificationStatus represents the status of a notification (authority or data subject)
type NotificationStatus string
const (
NotificationStatusNotRequired NotificationStatus = "not_required"
NotificationStatusPending NotificationStatus = "pending"
NotificationStatusSent NotificationStatus = "sent"
NotificationStatusConfirmed NotificationStatus = "confirmed"
)
// ============================================================================
// Main Entities
// ============================================================================
// Incident represents a security or data breach incident per DSGVO Art. 33/34
type Incident struct {
ID uuid.UUID `json:"id"`
TenantID uuid.UUID `json:"tenant_id"`
// Incident info
Title string `json:"title"`
Description string `json:"description,omitempty"`
Category IncidentCategory `json:"category"`
Status IncidentStatus `json:"status"`
Severity IncidentSeverity `json:"severity"`
// Detection & reporting
DetectedAt time.Time `json:"detected_at"`
ReportedBy uuid.UUID `json:"reported_by"`
// Affected scope
AffectedDataCategories []string `json:"affected_data_categories"` // JSONB
AffectedDataSubjectCount int `json:"affected_data_subject_count"`
AffectedSystems []string `json:"affected_systems"` // JSONB
// Assessments & notifications (JSONB embedded objects)
RiskAssessment *RiskAssessment `json:"risk_assessment,omitempty"`
AuthorityNotification *AuthorityNotification `json:"authority_notification,omitempty"`
DataSubjectNotification *DataSubjectNotification `json:"data_subject_notification,omitempty"`
// Resolution
RootCause string `json:"root_cause,omitempty"`
LessonsLearned string `json:"lessons_learned,omitempty"`
// Timeline (JSONB array)
Timeline []TimelineEntry `json:"timeline"`
// Audit
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
ClosedAt *time.Time `json:"closed_at,omitempty"`
}
// RiskAssessment contains the risk assessment for an incident
type RiskAssessment struct {
Likelihood int `json:"likelihood"` // 1-5
Impact int `json:"impact"` // 1-5
RiskLevel string `json:"risk_level"` // critical, high, medium, low (auto-calculated)
AssessedAt time.Time `json:"assessed_at"`
AssessedBy uuid.UUID `json:"assessed_by"`
Notes string `json:"notes,omitempty"`
}
// AuthorityNotification tracks the supervisory authority notification per DSGVO Art. 33
type AuthorityNotification struct {
Status NotificationStatus `json:"status"`
Deadline time.Time `json:"deadline"` // 72h from detected_at per Art. 33
SubmittedAt *time.Time `json:"submitted_at,omitempty"`
AuthorityName string `json:"authority_name,omitempty"`
ReferenceNumber string `json:"reference_number,omitempty"`
ContactPerson string `json:"contact_person,omitempty"`
Notes string `json:"notes,omitempty"`
}
// DataSubjectNotification tracks the data subject notification per DSGVO Art. 34
type DataSubjectNotification struct {
Required bool `json:"required"`
Status NotificationStatus `json:"status"`
SentAt *time.Time `json:"sent_at,omitempty"`
AffectedCount int `json:"affected_count"`
NotificationText string `json:"notification_text,omitempty"`
Channel string `json:"channel,omitempty"` // email, letter, website
}
// TimelineEntry represents a single event in the incident timeline
type TimelineEntry struct {
Timestamp time.Time `json:"timestamp"`
Action string `json:"action"`
UserID uuid.UUID `json:"user_id"`
Details string `json:"details,omitempty"`
}
// IncidentMeasure represents a corrective or preventive measure for an incident
type IncidentMeasure struct {
ID uuid.UUID `json:"id"`
IncidentID uuid.UUID `json:"incident_id"`
Title string `json:"title"`
Description string `json:"description,omitempty"`
MeasureType MeasureType `json:"measure_type"`
Status MeasureStatus `json:"status"`
Responsible string `json:"responsible,omitempty"`
DueDate *time.Time `json:"due_date,omitempty"`
CompletedAt *time.Time `json:"completed_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
// IncidentStatistics contains aggregated incident statistics for a tenant
type IncidentStatistics struct {
TotalIncidents int `json:"total_incidents"`
OpenIncidents int `json:"open_incidents"`
ByStatus map[string]int `json:"by_status"`
BySeverity map[string]int `json:"by_severity"`
ByCategory map[string]int `json:"by_category"`
NotificationsPending int `json:"notifications_pending"`
AvgResolutionHours float64 `json:"avg_resolution_hours"`
}
// ============================================================================
// API Request/Response Types
// ============================================================================
// CreateIncidentRequest is the API request for creating an incident
type CreateIncidentRequest struct {
Title string `json:"title" binding:"required"`
Description string `json:"description,omitempty"`
Category IncidentCategory `json:"category" binding:"required"`
Severity IncidentSeverity `json:"severity" binding:"required"`
DetectedAt *time.Time `json:"detected_at,omitempty"` // defaults to now
AffectedDataCategories []string `json:"affected_data_categories,omitempty"`
AffectedDataSubjectCount int `json:"affected_data_subject_count,omitempty"`
AffectedSystems []string `json:"affected_systems,omitempty"`
}
// UpdateIncidentRequest is the API request for updating an incident
type UpdateIncidentRequest struct {
Title string `json:"title,omitempty"`
Description string `json:"description,omitempty"`
Category IncidentCategory `json:"category,omitempty"`
Status IncidentStatus `json:"status,omitempty"`
Severity IncidentSeverity `json:"severity,omitempty"`
AffectedDataCategories []string `json:"affected_data_categories,omitempty"`
AffectedDataSubjectCount *int `json:"affected_data_subject_count,omitempty"`
AffectedSystems []string `json:"affected_systems,omitempty"`
}
// RiskAssessmentRequest is the API request for assessing risk
type RiskAssessmentRequest struct {
Likelihood int `json:"likelihood" binding:"required,min=1,max=5"`
Impact int `json:"impact" binding:"required,min=1,max=5"`
Notes string `json:"notes,omitempty"`
}
// SubmitAuthorityNotificationRequest is the API request for submitting authority notification
type SubmitAuthorityNotificationRequest struct {
AuthorityName string `json:"authority_name" binding:"required"`
ContactPerson string `json:"contact_person,omitempty"`
ReferenceNumber string `json:"reference_number,omitempty"`
Notes string `json:"notes,omitempty"`
}
// NotifyDataSubjectsRequest is the API request for notifying data subjects
type NotifyDataSubjectsRequest struct {
NotificationText string `json:"notification_text" binding:"required"`
Channel string `json:"channel" binding:"required"` // email, letter, website
AffectedCount int `json:"affected_count,omitempty"`
}
// AddMeasureRequest is the API request for adding a corrective measure
type AddMeasureRequest struct {
Title string `json:"title" binding:"required"`
Description string `json:"description,omitempty"`
MeasureType MeasureType `json:"measure_type" binding:"required"`
Responsible string `json:"responsible,omitempty"`
DueDate *time.Time `json:"due_date,omitempty"`
}
// CloseIncidentRequest is the API request for closing an incident
type CloseIncidentRequest struct {
RootCause string `json:"root_cause" binding:"required"`
LessonsLearned string `json:"lessons_learned,omitempty"`
}
// AddTimelineEntryRequest is the API request for adding a timeline entry
type AddTimelineEntryRequest struct {
Action string `json:"action" binding:"required"`
Details string `json:"details,omitempty"`
}
// IncidentListResponse is the API response for listing incidents
type IncidentListResponse struct {
Incidents []Incident `json:"incidents"`
Total int `json:"total"`
}
// IncidentFilters defines filters for listing incidents
type IncidentFilters struct {
Status IncidentStatus
Severity IncidentSeverity
Category IncidentCategory
Limit int
Offset int
}
// ============================================================================
// Helper Functions
// ============================================================================
// CalculateRiskLevel calculates the risk level from likelihood and impact scores.
// Risk score = likelihood * impact. Thresholds:
// - critical: score >= 20
// - high: score >= 12
// - medium: score >= 6
// - low: score < 6
func CalculateRiskLevel(likelihood, impact int) string {
score := likelihood * impact
switch {
case score >= 20:
return "critical"
case score >= 12:
return "high"
case score >= 6:
return "medium"
default:
return "low"
}
}
// Calculate72hDeadline calculates the 72-hour notification deadline per DSGVO Art. 33.
// The supervisory authority must be notified within 72 hours of becoming aware of a breach.
func Calculate72hDeadline(detectedAt time.Time) time.Time {
return detectedAt.Add(72 * time.Hour)
}
// IsNotificationRequired determines whether authority notification is required
// based on the assessed risk level. Notification is required for critical and high risk.
func IsNotificationRequired(riskLevel string) bool {
return riskLevel == "critical" || riskLevel == "high"
}

View File

@@ -0,0 +1,571 @@
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
}