package handlers import ( "fmt" "net/http" "time" "github.com/breakpilot/ai-compliance-sdk/internal/incidents" "github.com/breakpilot/ai-compliance-sdk/internal/rbac" "github.com/gin-gonic/gin" "github.com/google/uuid" ) // IncidentHandlers handles incident/breach management HTTP requests type IncidentHandlers struct { store *incidents.Store } // NewIncidentHandlers creates new incident handlers func NewIncidentHandlers(store *incidents.Store) *IncidentHandlers { return &IncidentHandlers{store: store} } // ============================================================================ // Incident CRUD // ============================================================================ // CreateIncident creates a new incident // POST /sdk/v1/incidents func (h *IncidentHandlers) CreateIncident(c *gin.Context) { var req incidents.CreateIncidentRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } tenantID := rbac.GetTenantID(c) userID := rbac.GetUserID(c) detectedAt := time.Now().UTC() if req.DetectedAt != nil { detectedAt = *req.DetectedAt } // Auto-calculate 72h deadline per DSGVO Art. 33 deadline := incidents.Calculate72hDeadline(detectedAt) incident := &incidents.Incident{ TenantID: tenantID, Title: req.Title, Description: req.Description, Category: req.Category, Status: incidents.IncidentStatusDetected, Severity: req.Severity, DetectedAt: detectedAt, ReportedBy: userID, AffectedDataCategories: req.AffectedDataCategories, AffectedDataSubjectCount: req.AffectedDataSubjectCount, AffectedSystems: req.AffectedSystems, AuthorityNotification: &incidents.AuthorityNotification{ Status: incidents.NotificationStatusPending, Deadline: deadline, }, DataSubjectNotification: &incidents.DataSubjectNotification{ Required: false, Status: incidents.NotificationStatusNotRequired, }, Timeline: []incidents.TimelineEntry{ { Timestamp: time.Now().UTC(), Action: "incident_created", UserID: userID, Details: "Incident detected and reported", }, }, } if err := h.store.CreateIncident(c.Request.Context(), incident); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusCreated, gin.H{ "incident": incident, "authority_deadline": deadline, "hours_until_deadline": time.Until(deadline).Hours(), }) } // GetIncident retrieves an incident by ID // GET /sdk/v1/incidents/:id func (h *IncidentHandlers) GetIncident(c *gin.Context) { id, err := uuid.Parse(c.Param("id")) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid incident ID"}) return } incident, err := h.store.GetIncident(c.Request.Context(), id) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } if incident == nil { c.JSON(http.StatusNotFound, gin.H{"error": "incident not found"}) return } // Get measures measures, _ := h.store.ListMeasures(c.Request.Context(), id) // Calculate deadline info if authority notification exists var deadlineInfo gin.H if incident.AuthorityNotification != nil { hoursRemaining := time.Until(incident.AuthorityNotification.Deadline).Hours() deadlineInfo = gin.H{ "deadline": incident.AuthorityNotification.Deadline, "hours_remaining": hoursRemaining, "overdue": hoursRemaining < 0, } } c.JSON(http.StatusOK, gin.H{ "incident": incident, "measures": measures, "deadline_info": deadlineInfo, }) } // ListIncidents lists incidents for a tenant // GET /sdk/v1/incidents func (h *IncidentHandlers) ListIncidents(c *gin.Context) { tenantID := rbac.GetTenantID(c) filters := &incidents.IncidentFilters{ Limit: 50, } if status := c.Query("status"); status != "" { filters.Status = incidents.IncidentStatus(status) } if severity := c.Query("severity"); severity != "" { filters.Severity = incidents.IncidentSeverity(severity) } if category := c.Query("category"); category != "" { filters.Category = incidents.IncidentCategory(category) } incidentList, total, err := h.store.ListIncidents(c.Request.Context(), tenantID, filters) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, incidents.IncidentListResponse{ Incidents: incidentList, Total: total, }) } // UpdateIncident updates an incident // PUT /sdk/v1/incidents/:id func (h *IncidentHandlers) UpdateIncident(c *gin.Context) { id, err := uuid.Parse(c.Param("id")) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid incident ID"}) return } incident, err := h.store.GetIncident(c.Request.Context(), id) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } if incident == nil { c.JSON(http.StatusNotFound, gin.H{"error": "incident not found"}) return } var req incidents.UpdateIncidentRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } if req.Title != "" { incident.Title = req.Title } if req.Description != "" { incident.Description = req.Description } if req.Category != "" { incident.Category = req.Category } if req.Status != "" { incident.Status = req.Status } if req.Severity != "" { incident.Severity = req.Severity } if req.AffectedDataCategories != nil { incident.AffectedDataCategories = req.AffectedDataCategories } if req.AffectedDataSubjectCount != nil { incident.AffectedDataSubjectCount = *req.AffectedDataSubjectCount } if req.AffectedSystems != nil { incident.AffectedSystems = req.AffectedSystems } if err := h.store.UpdateIncident(c.Request.Context(), incident); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{"incident": incident}) } // DeleteIncident deletes an incident // DELETE /sdk/v1/incidents/:id func (h *IncidentHandlers) DeleteIncident(c *gin.Context) { id, err := uuid.Parse(c.Param("id")) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid incident ID"}) return } if err := h.store.DeleteIncident(c.Request.Context(), id); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{"message": "incident deleted"}) } // ============================================================================ // Risk Assessment // ============================================================================ // AssessRisk performs a risk assessment for an incident // POST /sdk/v1/incidents/:id/risk-assessment func (h *IncidentHandlers) AssessRisk(c *gin.Context) { id, err := uuid.Parse(c.Param("id")) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid incident ID"}) return } incident, err := h.store.GetIncident(c.Request.Context(), id) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } if incident == nil { c.JSON(http.StatusNotFound, gin.H{"error": "incident not found"}) return } var req incidents.RiskAssessmentRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } userID := rbac.GetUserID(c) // Auto-calculate risk level riskLevel := incidents.CalculateRiskLevel(req.Likelihood, req.Impact) notificationRequired := incidents.IsNotificationRequired(riskLevel) assessment := &incidents.RiskAssessment{ Likelihood: req.Likelihood, Impact: req.Impact, RiskLevel: riskLevel, AssessedAt: time.Now().UTC(), AssessedBy: userID, Notes: req.Notes, } if err := h.store.UpdateRiskAssessment(c.Request.Context(), id, assessment); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } // Update status to assessment incident.Status = incidents.IncidentStatusAssessment h.store.UpdateIncident(c.Request.Context(), incident) // Add timeline entry h.store.AddTimelineEntry(c.Request.Context(), id, incidents.TimelineEntry{ Timestamp: time.Now().UTC(), Action: "risk_assessed", UserID: userID, Details: fmt.Sprintf("Risk level: %s (likelihood=%d, impact=%d)", riskLevel, req.Likelihood, req.Impact), }) // If notification is required, update authority notification status if notificationRequired && incident.AuthorityNotification != nil { incident.AuthorityNotification.Status = incidents.NotificationStatusPending h.store.UpdateAuthorityNotification(c.Request.Context(), id, incident.AuthorityNotification) // Update status to notification_required incident.Status = incidents.IncidentStatusNotificationRequired h.store.UpdateIncident(c.Request.Context(), incident) } c.JSON(http.StatusOK, gin.H{ "risk_assessment": assessment, "notification_required": notificationRequired, "incident_status": incident.Status, }) } // ============================================================================ // Authority Notification (Art. 33) // ============================================================================ // SubmitAuthorityNotification submits the supervisory authority notification // POST /sdk/v1/incidents/:id/authority-notification func (h *IncidentHandlers) SubmitAuthorityNotification(c *gin.Context) { id, err := uuid.Parse(c.Param("id")) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid incident ID"}) return } incident, err := h.store.GetIncident(c.Request.Context(), id) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } if incident == nil { c.JSON(http.StatusNotFound, gin.H{"error": "incident not found"}) return } var req incidents.SubmitAuthorityNotificationRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } userID := rbac.GetUserID(c) now := time.Now().UTC() // Preserve existing deadline deadline := incidents.Calculate72hDeadline(incident.DetectedAt) if incident.AuthorityNotification != nil { deadline = incident.AuthorityNotification.Deadline } notification := &incidents.AuthorityNotification{ Status: incidents.NotificationStatusSent, Deadline: deadline, SubmittedAt: &now, AuthorityName: req.AuthorityName, ReferenceNumber: req.ReferenceNumber, ContactPerson: req.ContactPerson, Notes: req.Notes, } if err := h.store.UpdateAuthorityNotification(c.Request.Context(), id, notification); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } // Update incident status incident.Status = incidents.IncidentStatusNotificationSent h.store.UpdateIncident(c.Request.Context(), incident) // Add timeline entry h.store.AddTimelineEntry(c.Request.Context(), id, incidents.TimelineEntry{ Timestamp: now, Action: "authority_notified", UserID: userID, Details: "Authority notification submitted to " + req.AuthorityName, }) c.JSON(http.StatusOK, gin.H{ "authority_notification": notification, "submitted_within_72h": now.Before(deadline), }) } // ============================================================================ // Data Subject Notification (Art. 34) // ============================================================================ // NotifyDataSubjects submits the data subject notification // POST /sdk/v1/incidents/:id/data-subject-notification func (h *IncidentHandlers) NotifyDataSubjects(c *gin.Context) { id, err := uuid.Parse(c.Param("id")) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid incident ID"}) return } incident, err := h.store.GetIncident(c.Request.Context(), id) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } if incident == nil { c.JSON(http.StatusNotFound, gin.H{"error": "incident not found"}) return } var req incidents.NotifyDataSubjectsRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } userID := rbac.GetUserID(c) now := time.Now().UTC() affectedCount := req.AffectedCount if affectedCount == 0 { affectedCount = incident.AffectedDataSubjectCount } notification := &incidents.DataSubjectNotification{ Required: true, Status: incidents.NotificationStatusSent, SentAt: &now, AffectedCount: affectedCount, NotificationText: req.NotificationText, Channel: req.Channel, } if err := h.store.UpdateDataSubjectNotification(c.Request.Context(), id, notification); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } // Add timeline entry h.store.AddTimelineEntry(c.Request.Context(), id, incidents.TimelineEntry{ Timestamp: now, Action: "data_subjects_notified", UserID: userID, Details: "Data subjects notified via " + req.Channel + " (" + fmt.Sprintf("%d", affectedCount) + " affected)", }) c.JSON(http.StatusOK, gin.H{ "data_subject_notification": notification, }) } // ============================================================================ // Measures // ============================================================================ // AddMeasure adds a corrective measure to an incident // POST /sdk/v1/incidents/:id/measures func (h *IncidentHandlers) AddMeasure(c *gin.Context) { incidentID, err := uuid.Parse(c.Param("id")) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid incident ID"}) return } // Verify incident exists incident, err := h.store.GetIncident(c.Request.Context(), incidentID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } if incident == nil { c.JSON(http.StatusNotFound, gin.H{"error": "incident not found"}) return } var req incidents.AddMeasureRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } userID := rbac.GetUserID(c) measure := &incidents.IncidentMeasure{ IncidentID: incidentID, Title: req.Title, Description: req.Description, MeasureType: req.MeasureType, Status: incidents.MeasureStatusPlanned, Responsible: req.Responsible, DueDate: req.DueDate, } if err := h.store.AddMeasure(c.Request.Context(), measure); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } // Add timeline entry h.store.AddTimelineEntry(c.Request.Context(), incidentID, incidents.TimelineEntry{ Timestamp: time.Now().UTC(), Action: "measure_added", UserID: userID, Details: "Measure added: " + req.Title + " (" + string(req.MeasureType) + ")", }) c.JSON(http.StatusCreated, gin.H{"measure": measure}) } // UpdateMeasure updates a measure // PUT /sdk/v1/incidents/measures/:measureId func (h *IncidentHandlers) UpdateMeasure(c *gin.Context) { measureID, err := uuid.Parse(c.Param("measureId")) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid measure ID"}) return } var req struct { Title string `json:"title,omitempty"` Description string `json:"description,omitempty"` MeasureType incidents.MeasureType `json:"measure_type,omitempty"` Status incidents.MeasureStatus `json:"status,omitempty"` Responsible string `json:"responsible,omitempty"` DueDate *time.Time `json:"due_date,omitempty"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } measure := &incidents.IncidentMeasure{ ID: measureID, Title: req.Title, Description: req.Description, MeasureType: req.MeasureType, Status: req.Status, Responsible: req.Responsible, DueDate: req.DueDate, } if err := h.store.UpdateMeasure(c.Request.Context(), measure); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{"measure": measure}) } // CompleteMeasure marks a measure as completed // POST /sdk/v1/incidents/measures/:measureId/complete func (h *IncidentHandlers) CompleteMeasure(c *gin.Context) { measureID, err := uuid.Parse(c.Param("measureId")) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid measure ID"}) return } if err := h.store.CompleteMeasure(c.Request.Context(), measureID); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{"message": "measure completed"}) } // ============================================================================ // Timeline // ============================================================================ // AddTimelineEntry adds a timeline entry to an incident // POST /sdk/v1/incidents/:id/timeline func (h *IncidentHandlers) AddTimelineEntry(c *gin.Context) { id, err := uuid.Parse(c.Param("id")) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid incident ID"}) return } var req incidents.AddTimelineEntryRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } userID := rbac.GetUserID(c) entry := incidents.TimelineEntry{ Timestamp: time.Now().UTC(), Action: req.Action, UserID: userID, Details: req.Details, } if err := h.store.AddTimelineEntry(c.Request.Context(), id, entry); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusCreated, gin.H{"timeline_entry": entry}) } // ============================================================================ // Close Incident // ============================================================================ // CloseIncident closes an incident with root cause analysis // POST /sdk/v1/incidents/:id/close func (h *IncidentHandlers) CloseIncident(c *gin.Context) { id, err := uuid.Parse(c.Param("id")) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid incident ID"}) return } incident, err := h.store.GetIncident(c.Request.Context(), id) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } if incident == nil { c.JSON(http.StatusNotFound, gin.H{"error": "incident not found"}) return } var req incidents.CloseIncidentRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } userID := rbac.GetUserID(c) if err := h.store.CloseIncident(c.Request.Context(), id, req.RootCause, req.LessonsLearned); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } // Add timeline entry h.store.AddTimelineEntry(c.Request.Context(), id, incidents.TimelineEntry{ Timestamp: time.Now().UTC(), Action: "incident_closed", UserID: userID, Details: "Incident closed. Root cause: " + req.RootCause, }) c.JSON(http.StatusOK, gin.H{ "message": "incident closed", "root_cause": req.RootCause, "lessons_learned": req.LessonsLearned, }) } // ============================================================================ // Statistics // ============================================================================ // GetStatistics returns aggregated incident statistics // GET /sdk/v1/incidents/statistics func (h *IncidentHandlers) GetStatistics(c *gin.Context) { tenantID := rbac.GetTenantID(c) stats, err := h.store.GetStatistics(c.Request.Context(), tenantID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, stats) }