feat: Add Academy, Whistleblower, Incidents SDK modules, pitch-deck, blog and CI/CD config
Some checks failed
ci/woodpecker/push/integration Pipeline failed
ci/woodpecker/push/main Pipeline failed
CI/CD Pipeline / Go Tests (push) Has been cancelled
CI/CD Pipeline / Python Tests (push) Has been cancelled
CI/CD Pipeline / Website Tests (push) Has been cancelled
CI/CD Pipeline / Linting (push) Has been cancelled
CI/CD Pipeline / Security Scan (push) Has been cancelled
CI/CD Pipeline / Docker Build & Push (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Deploy to Staging (push) Has been cancelled
CI/CD Pipeline / Deploy to Production (push) Has been cancelled
CI/CD Pipeline / CI Summary (push) Has been cancelled
Security Scanning / Secret Scanning (push) Has been cancelled
Security Scanning / Dependency Vulnerability Scan (push) Has been cancelled
Security Scanning / Go Security Scan (push) Has been cancelled
Security Scanning / Python Security Scan (push) Has been cancelled
Security Scanning / Node.js Security Scan (push) Has been cancelled
Security Scanning / Docker Image Security (push) Has been cancelled
Security Scanning / Security Summary (push) Has been cancelled
Tests / Go Tests (push) Has been cancelled
Tests / Python Tests (push) Has been cancelled
Tests / Integration Tests (push) Has been cancelled
Tests / Go Lint (push) Has been cancelled
Tests / Python Lint (push) Has been cancelled
Tests / Security Scan (push) Has been cancelled
Tests / All Checks Passed (push) Has been cancelled

- Academy, Whistleblower, Incidents frontend pages with API proxies and types
- Vendor compliance API proxy route
- Go backend handlers and models for all new SDK modules
- Investor pitch-deck app with interactive slides
- Blog section with DSGVO, AI Act, NIS2, glossary articles
- MkDocs documentation site
- CI/CD pipelines (Woodpecker, GitHub Actions), security scanning config
- Planning and implementation documentation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
BreakPilot Dev
2026-02-13 21:12:16 +01:00
parent d7ba705562
commit 557305db5d
208 changed files with 141969 additions and 5680 deletions

View File

@@ -0,0 +1,587 @@
package handlers
import (
"net/http"
"strconv"
"time"
"github.com/breakpilot/ai-compliance-sdk/internal/academy"
"github.com/breakpilot/ai-compliance-sdk/internal/rbac"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
// AcademyHandlers handles academy HTTP requests
type AcademyHandlers struct {
store *academy.Store
}
// NewAcademyHandlers creates new academy handlers
func NewAcademyHandlers(store *academy.Store) *AcademyHandlers {
return &AcademyHandlers{store: store}
}
// ============================================================================
// Course Management
// ============================================================================
// CreateCourse creates a new compliance training course
// POST /sdk/v1/academy/courses
func (h *AcademyHandlers) CreateCourse(c *gin.Context) {
var req academy.CreateCourseRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
tenantID := rbac.GetTenantID(c)
course := &academy.Course{
TenantID: tenantID,
Title: req.Title,
Description: req.Description,
Category: req.Category,
DurationMinutes: req.DurationMinutes,
RequiredForRoles: req.RequiredForRoles,
IsActive: true,
}
if course.RequiredForRoles == nil {
course.RequiredForRoles = []string{}
}
if err := h.store.CreateCourse(c.Request.Context(), course); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// Create lessons if provided
for i := range req.Lessons {
lesson := &academy.Lesson{
CourseID: course.ID,
Title: req.Lessons[i].Title,
Description: req.Lessons[i].Description,
LessonType: req.Lessons[i].LessonType,
ContentURL: req.Lessons[i].ContentURL,
DurationMinutes: req.Lessons[i].DurationMinutes,
OrderIndex: req.Lessons[i].OrderIndex,
QuizQuestions: req.Lessons[i].QuizQuestions,
}
if err := h.store.CreateLesson(c.Request.Context(), lesson); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
course.Lessons = append(course.Lessons, *lesson)
}
if course.Lessons == nil {
course.Lessons = []academy.Lesson{}
}
c.JSON(http.StatusCreated, gin.H{"course": course})
}
// GetCourse retrieves a course with its lessons
// GET /sdk/v1/academy/courses/:id
func (h *AcademyHandlers) GetCourse(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid course ID"})
return
}
course, err := h.store.GetCourse(c.Request.Context(), id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if course == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "course not found"})
return
}
c.JSON(http.StatusOK, gin.H{"course": course})
}
// ListCourses lists courses for the current tenant
// GET /sdk/v1/academy/courses
func (h *AcademyHandlers) ListCourses(c *gin.Context) {
tenantID := rbac.GetTenantID(c)
filters := &academy.CourseFilters{
Limit: 50,
}
if category := c.Query("category"); category != "" {
filters.Category = academy.CourseCategory(category)
}
if search := c.Query("search"); search != "" {
filters.Search = search
}
if activeStr := c.Query("is_active"); activeStr != "" {
active := activeStr == "true"
filters.IsActive = &active
}
if limitStr := c.Query("limit"); limitStr != "" {
if limit, err := strconv.Atoi(limitStr); err == nil && limit > 0 {
filters.Limit = limit
}
}
if offsetStr := c.Query("offset"); offsetStr != "" {
if offset, err := strconv.Atoi(offsetStr); err == nil && offset >= 0 {
filters.Offset = offset
}
}
courses, total, err := h.store.ListCourses(c.Request.Context(), tenantID, filters)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, academy.CourseListResponse{
Courses: courses,
Total: total,
})
}
// UpdateCourse updates a course
// PUT /sdk/v1/academy/courses/:id
func (h *AcademyHandlers) UpdateCourse(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid course ID"})
return
}
course, err := h.store.GetCourse(c.Request.Context(), id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if course == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "course not found"})
return
}
var req academy.UpdateCourseRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if req.Title != nil {
course.Title = *req.Title
}
if req.Description != nil {
course.Description = *req.Description
}
if req.Category != nil {
course.Category = *req.Category
}
if req.DurationMinutes != nil {
course.DurationMinutes = *req.DurationMinutes
}
if req.RequiredForRoles != nil {
course.RequiredForRoles = req.RequiredForRoles
}
if req.IsActive != nil {
course.IsActive = *req.IsActive
}
if err := h.store.UpdateCourse(c.Request.Context(), course); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"course": course})
}
// DeleteCourse deletes a course
// DELETE /sdk/v1/academy/courses/:id
func (h *AcademyHandlers) DeleteCourse(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid course ID"})
return
}
if err := h.store.DeleteCourse(c.Request.Context(), id); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "course deleted"})
}
// ============================================================================
// Enrollment Management
// ============================================================================
// CreateEnrollment enrolls a user in a course
// POST /sdk/v1/academy/enrollments
func (h *AcademyHandlers) CreateEnrollment(c *gin.Context) {
var req academy.EnrollUserRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
tenantID := rbac.GetTenantID(c)
// Verify course exists
course, err := h.store.GetCourse(c.Request.Context(), req.CourseID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if course == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "course not found"})
return
}
enrollment := &academy.Enrollment{
TenantID: tenantID,
CourseID: req.CourseID,
UserID: req.UserID,
UserName: req.UserName,
UserEmail: req.UserEmail,
Status: academy.EnrollmentStatusNotStarted,
Deadline: req.Deadline,
}
if err := h.store.CreateEnrollment(c.Request.Context(), enrollment); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, gin.H{"enrollment": enrollment})
}
// ListEnrollments lists enrollments for the current tenant
// GET /sdk/v1/academy/enrollments
func (h *AcademyHandlers) ListEnrollments(c *gin.Context) {
tenantID := rbac.GetTenantID(c)
filters := &academy.EnrollmentFilters{
Limit: 50,
}
if status := c.Query("status"); status != "" {
filters.Status = academy.EnrollmentStatus(status)
}
if courseIDStr := c.Query("course_id"); courseIDStr != "" {
if courseID, err := uuid.Parse(courseIDStr); err == nil {
filters.CourseID = &courseID
}
}
if userIDStr := c.Query("user_id"); userIDStr != "" {
if userID, err := uuid.Parse(userIDStr); err == nil {
filters.UserID = &userID
}
}
if limitStr := c.Query("limit"); limitStr != "" {
if limit, err := strconv.Atoi(limitStr); err == nil && limit > 0 {
filters.Limit = limit
}
}
if offsetStr := c.Query("offset"); offsetStr != "" {
if offset, err := strconv.Atoi(offsetStr); err == nil && offset >= 0 {
filters.Offset = offset
}
}
enrollments, total, err := h.store.ListEnrollments(c.Request.Context(), tenantID, filters)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, academy.EnrollmentListResponse{
Enrollments: enrollments,
Total: total,
})
}
// UpdateProgress updates an enrollment's progress
// PUT /sdk/v1/academy/enrollments/:id/progress
func (h *AcademyHandlers) UpdateProgress(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid enrollment ID"})
return
}
enrollment, err := h.store.GetEnrollment(c.Request.Context(), id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if enrollment == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "enrollment not found"})
return
}
var req academy.UpdateProgressRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if req.Progress < 0 || req.Progress > 100 {
c.JSON(http.StatusBadRequest, gin.H{"error": "progress must be between 0 and 100"})
return
}
if err := h.store.UpdateEnrollmentProgress(c.Request.Context(), id, req.Progress, req.CurrentLesson); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// Fetch updated enrollment
updated, err := h.store.GetEnrollment(c.Request.Context(), id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"enrollment": updated})
}
// CompleteEnrollment marks an enrollment as completed
// POST /sdk/v1/academy/enrollments/:id/complete
func (h *AcademyHandlers) CompleteEnrollment(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid enrollment ID"})
return
}
enrollment, err := h.store.GetEnrollment(c.Request.Context(), id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if enrollment == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "enrollment not found"})
return
}
if enrollment.Status == academy.EnrollmentStatusCompleted {
c.JSON(http.StatusBadRequest, gin.H{"error": "enrollment already completed"})
return
}
if err := h.store.CompleteEnrollment(c.Request.Context(), id); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// Fetch updated enrollment
updated, err := h.store.GetEnrollment(c.Request.Context(), id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"enrollment": updated,
"message": "enrollment completed",
})
}
// ============================================================================
// Certificate Management
// ============================================================================
// GetCertificate retrieves a certificate
// GET /sdk/v1/academy/certificates/:id
func (h *AcademyHandlers) GetCertificate(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid certificate ID"})
return
}
cert, err := h.store.GetCertificate(c.Request.Context(), id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if cert == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "certificate not found"})
return
}
c.JSON(http.StatusOK, gin.H{"certificate": cert})
}
// GenerateCertificate generates a certificate for a completed enrollment
// POST /sdk/v1/academy/enrollments/:id/certificate
func (h *AcademyHandlers) GenerateCertificate(c *gin.Context) {
enrollmentID, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid enrollment ID"})
return
}
enrollment, err := h.store.GetEnrollment(c.Request.Context(), enrollmentID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if enrollment == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "enrollment not found"})
return
}
if enrollment.Status != academy.EnrollmentStatusCompleted {
c.JSON(http.StatusBadRequest, gin.H{"error": "enrollment must be completed before generating certificate"})
return
}
// Check if certificate already exists
existing, err := h.store.GetCertificateByEnrollment(c.Request.Context(), enrollmentID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if existing != nil {
c.JSON(http.StatusOK, gin.H{"certificate": existing, "message": "certificate already exists"})
return
}
// Get the course for the certificate title
course, err := h.store.GetCourse(c.Request.Context(), enrollment.CourseID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
courseTitle := "Unknown Course"
if course != nil {
courseTitle = course.Title
}
// Certificate is valid for 1 year by default
validUntil := time.Now().UTC().AddDate(1, 0, 0)
cert := &academy.Certificate{
EnrollmentID: enrollmentID,
UserName: enrollment.UserName,
CourseTitle: courseTitle,
ValidUntil: &validUntil,
}
if err := h.store.CreateCertificate(c.Request.Context(), cert); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, gin.H{"certificate": cert})
}
// ============================================================================
// Quiz Submission
// ============================================================================
// SubmitQuiz submits quiz answers and returns the results
// POST /sdk/v1/academy/enrollments/:id/quiz
func (h *AcademyHandlers) SubmitQuiz(c *gin.Context) {
enrollmentID, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid enrollment ID"})
return
}
var req academy.SubmitQuizRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Verify enrollment exists
enrollment, err := h.store.GetEnrollment(c.Request.Context(), enrollmentID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if enrollment == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "enrollment not found"})
return
}
// Get the lesson with quiz questions
lesson, err := h.store.GetLesson(c.Request.Context(), req.LessonID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if lesson == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "lesson not found"})
return
}
if len(lesson.QuizQuestions) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "lesson has no quiz questions"})
return
}
if len(req.Answers) != len(lesson.QuizQuestions) {
c.JSON(http.StatusBadRequest, gin.H{"error": "number of answers must match number of questions"})
return
}
// Grade the quiz
correctCount := 0
var results []academy.QuizResult
for i, question := range lesson.QuizQuestions {
correct := req.Answers[i] == question.CorrectIndex
if correct {
correctCount++
}
results = append(results, academy.QuizResult{
Question: question.Question,
Correct: correct,
Explanation: question.Explanation,
})
}
totalQuestions := len(lesson.QuizQuestions)
score := 0
if totalQuestions > 0 {
score = (correctCount * 100) / totalQuestions
}
// Pass threshold: 70%
passed := score >= 70
response := academy.SubmitQuizResponse{
Score: score,
Passed: passed,
CorrectAnswers: correctCount,
TotalQuestions: totalQuestions,
Results: results,
}
c.JSON(http.StatusOK, response)
}
// ============================================================================
// Statistics
// ============================================================================
// GetStatistics returns academy statistics for the current tenant
// GET /sdk/v1/academy/statistics
func (h *AcademyHandlers) 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)
}

View File

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

View File

@@ -0,0 +1,850 @@
package handlers
import (
"encoding/json"
"net/http"
"time"
"github.com/breakpilot/ai-compliance-sdk/internal/rbac"
"github.com/breakpilot/ai-compliance-sdk/internal/vendor"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
// VendorHandlers handles vendor-compliance HTTP requests
type VendorHandlers struct {
store *vendor.Store
}
// NewVendorHandlers creates new vendor handlers
func NewVendorHandlers(store *vendor.Store) *VendorHandlers {
return &VendorHandlers{store: store}
}
// ============================================================================
// Vendor CRUD
// ============================================================================
// CreateVendor creates a new vendor
// POST /sdk/v1/vendors
func (h *VendorHandlers) CreateVendor(c *gin.Context) {
var req vendor.CreateVendorRequest
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)
v := &vendor.Vendor{
TenantID: tenantID,
Name: req.Name,
LegalForm: req.LegalForm,
Country: req.Country,
Address: req.Address,
Website: req.Website,
ContactName: req.ContactName,
ContactEmail: req.ContactEmail,
ContactPhone: req.ContactPhone,
ContactDepartment: req.ContactDepartment,
Role: req.Role,
ServiceCategory: req.ServiceCategory,
ServiceDescription: req.ServiceDescription,
DataAccessLevel: req.DataAccessLevel,
ProcessingLocations: req.ProcessingLocations,
Certifications: req.Certifications,
ReviewFrequency: req.ReviewFrequency,
ProcessingActivityIDs: req.ProcessingActivityIDs,
TemplateID: req.TemplateID,
Status: vendor.VendorStatusActive,
CreatedBy: userID.String(),
}
if err := h.store.CreateVendor(c.Request.Context(), v); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, gin.H{"vendor": v})
}
// ListVendors lists all vendors for a tenant
// GET /sdk/v1/vendors
func (h *VendorHandlers) ListVendors(c *gin.Context) {
tenantID := rbac.GetTenantID(c)
vendors, err := h.store.ListVendors(c.Request.Context(), tenantID.String())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"vendors": vendors,
"total": len(vendors),
})
}
// GetVendor retrieves a vendor by ID with contracts and findings
// GET /sdk/v1/vendors/:id
func (h *VendorHandlers) GetVendor(c *gin.Context) {
tenantID := rbac.GetTenantID(c)
id := c.Param("id")
v, err := h.store.GetVendor(c.Request.Context(), tenantID.String(), id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if v == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "vendor not found"})
return
}
contracts, _ := h.store.ListContracts(c.Request.Context(), tenantID.String(), &id)
findings, _ := h.store.ListFindings(c.Request.Context(), tenantID.String(), &id, nil)
c.JSON(http.StatusOK, gin.H{
"vendor": v,
"contracts": contracts,
"findings": findings,
})
}
// UpdateVendor updates a vendor
// PUT /sdk/v1/vendors/:id
func (h *VendorHandlers) UpdateVendor(c *gin.Context) {
tenantID := rbac.GetTenantID(c)
id := c.Param("id")
v, err := h.store.GetVendor(c.Request.Context(), tenantID.String(), id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if v == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "vendor not found"})
return
}
var req vendor.UpdateVendorRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Apply non-nil fields
if req.Name != nil {
v.Name = *req.Name
}
if req.LegalForm != nil {
v.LegalForm = *req.LegalForm
}
if req.Country != nil {
v.Country = *req.Country
}
if req.Address != nil {
v.Address = req.Address
}
if req.Website != nil {
v.Website = *req.Website
}
if req.ContactName != nil {
v.ContactName = *req.ContactName
}
if req.ContactEmail != nil {
v.ContactEmail = *req.ContactEmail
}
if req.ContactPhone != nil {
v.ContactPhone = *req.ContactPhone
}
if req.ContactDepartment != nil {
v.ContactDepartment = *req.ContactDepartment
}
if req.Role != nil {
v.Role = *req.Role
}
if req.ServiceCategory != nil {
v.ServiceCategory = *req.ServiceCategory
}
if req.ServiceDescription != nil {
v.ServiceDescription = *req.ServiceDescription
}
if req.DataAccessLevel != nil {
v.DataAccessLevel = *req.DataAccessLevel
}
if req.ProcessingLocations != nil {
v.ProcessingLocations = req.ProcessingLocations
}
if req.Certifications != nil {
v.Certifications = req.Certifications
}
if req.InherentRiskScore != nil {
v.InherentRiskScore = req.InherentRiskScore
}
if req.ResidualRiskScore != nil {
v.ResidualRiskScore = req.ResidualRiskScore
}
if req.ManualRiskAdjustment != nil {
v.ManualRiskAdjustment = req.ManualRiskAdjustment
}
if req.ReviewFrequency != nil {
v.ReviewFrequency = *req.ReviewFrequency
}
if req.LastReviewDate != nil {
v.LastReviewDate = req.LastReviewDate
}
if req.NextReviewDate != nil {
v.NextReviewDate = req.NextReviewDate
}
if req.ProcessingActivityIDs != nil {
v.ProcessingActivityIDs = req.ProcessingActivityIDs
}
if req.Status != nil {
v.Status = *req.Status
}
if req.TemplateID != nil {
v.TemplateID = req.TemplateID
}
if err := h.store.UpdateVendor(c.Request.Context(), v); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"vendor": v})
}
// DeleteVendor deletes a vendor
// DELETE /sdk/v1/vendors/:id
func (h *VendorHandlers) DeleteVendor(c *gin.Context) {
tenantID := rbac.GetTenantID(c)
id := c.Param("id")
if err := h.store.DeleteVendor(c.Request.Context(), tenantID.String(), id); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "vendor deleted"})
}
// ============================================================================
// Contract CRUD
// ============================================================================
// CreateContract creates a new contract for a vendor
// POST /sdk/v1/vendors/contracts
func (h *VendorHandlers) CreateContract(c *gin.Context) {
var req vendor.CreateContractRequest
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)
contract := &vendor.Contract{
TenantID: tenantID,
VendorID: req.VendorID,
FileName: req.FileName,
OriginalName: req.OriginalName,
MimeType: req.MimeType,
FileSize: req.FileSize,
StoragePath: req.StoragePath,
DocumentType: req.DocumentType,
Parties: req.Parties,
EffectiveDate: req.EffectiveDate,
ExpirationDate: req.ExpirationDate,
AutoRenewal: req.AutoRenewal,
RenewalNoticePeriod: req.RenewalNoticePeriod,
Version: req.Version,
PreviousVersionID: req.PreviousVersionID,
ReviewStatus: "PENDING",
CreatedBy: userID.String(),
}
if contract.Version == "" {
contract.Version = "1.0"
}
if err := h.store.CreateContract(c.Request.Context(), contract); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, gin.H{"contract": contract})
}
// ListContracts lists contracts for a tenant
// GET /sdk/v1/vendors/contracts
func (h *VendorHandlers) ListContracts(c *gin.Context) {
tenantID := rbac.GetTenantID(c)
var vendorID *string
if vid := c.Query("vendor_id"); vid != "" {
vendorID = &vid
}
contracts, err := h.store.ListContracts(c.Request.Context(), tenantID.String(), vendorID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"contracts": contracts,
"total": len(contracts),
})
}
// GetContract retrieves a contract by ID
// GET /sdk/v1/vendors/contracts/:id
func (h *VendorHandlers) GetContract(c *gin.Context) {
tenantID := rbac.GetTenantID(c)
id := c.Param("id")
contract, err := h.store.GetContract(c.Request.Context(), tenantID.String(), id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if contract == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "contract not found"})
return
}
c.JSON(http.StatusOK, gin.H{"contract": contract})
}
// UpdateContract updates a contract
// PUT /sdk/v1/vendors/contracts/:id
func (h *VendorHandlers) UpdateContract(c *gin.Context) {
tenantID := rbac.GetTenantID(c)
id := c.Param("id")
contract, err := h.store.GetContract(c.Request.Context(), tenantID.String(), id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if contract == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "contract not found"})
return
}
var req vendor.UpdateContractRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if req.DocumentType != nil {
contract.DocumentType = *req.DocumentType
}
if req.Parties != nil {
contract.Parties = req.Parties
}
if req.EffectiveDate != nil {
contract.EffectiveDate = req.EffectiveDate
}
if req.ExpirationDate != nil {
contract.ExpirationDate = req.ExpirationDate
}
if req.AutoRenewal != nil {
contract.AutoRenewal = *req.AutoRenewal
}
if req.RenewalNoticePeriod != nil {
contract.RenewalNoticePeriod = *req.RenewalNoticePeriod
}
if req.ReviewStatus != nil {
contract.ReviewStatus = *req.ReviewStatus
}
if req.ReviewCompletedAt != nil {
contract.ReviewCompletedAt = req.ReviewCompletedAt
}
if req.ComplianceScore != nil {
contract.ComplianceScore = req.ComplianceScore
}
if req.Version != nil {
contract.Version = *req.Version
}
if req.ExtractedText != nil {
contract.ExtractedText = *req.ExtractedText
}
if req.PageCount != nil {
contract.PageCount = req.PageCount
}
if err := h.store.UpdateContract(c.Request.Context(), contract); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"contract": contract})
}
// DeleteContract deletes a contract
// DELETE /sdk/v1/vendors/contracts/:id
func (h *VendorHandlers) DeleteContract(c *gin.Context) {
tenantID := rbac.GetTenantID(c)
id := c.Param("id")
if err := h.store.DeleteContract(c.Request.Context(), tenantID.String(), id); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "contract deleted"})
}
// ============================================================================
// Finding CRUD
// ============================================================================
// CreateFinding creates a new compliance finding
// POST /sdk/v1/vendors/findings
func (h *VendorHandlers) CreateFinding(c *gin.Context) {
var req vendor.CreateFindingRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
tenantID := rbac.GetTenantID(c)
finding := &vendor.Finding{
TenantID: tenantID,
VendorID: req.VendorID,
ContractID: req.ContractID,
FindingType: req.FindingType,
Category: req.Category,
Severity: req.Severity,
Title: req.Title,
Description: req.Description,
Recommendation: req.Recommendation,
Citations: req.Citations,
Status: vendor.FindingStatusOpen,
Assignee: req.Assignee,
DueDate: req.DueDate,
}
if err := h.store.CreateFinding(c.Request.Context(), finding); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, gin.H{"finding": finding})
}
// ListFindings lists findings for a tenant
// GET /sdk/v1/vendors/findings
func (h *VendorHandlers) ListFindings(c *gin.Context) {
tenantID := rbac.GetTenantID(c)
var vendorID, contractID *string
if vid := c.Query("vendor_id"); vid != "" {
vendorID = &vid
}
if cid := c.Query("contract_id"); cid != "" {
contractID = &cid
}
findings, err := h.store.ListFindings(c.Request.Context(), tenantID.String(), vendorID, contractID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"findings": findings,
"total": len(findings),
})
}
// GetFinding retrieves a finding by ID
// GET /sdk/v1/vendors/findings/:id
func (h *VendorHandlers) GetFinding(c *gin.Context) {
tenantID := rbac.GetTenantID(c)
id := c.Param("id")
finding, err := h.store.GetFinding(c.Request.Context(), tenantID.String(), id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if finding == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "finding not found"})
return
}
c.JSON(http.StatusOK, gin.H{"finding": finding})
}
// UpdateFinding updates a finding
// PUT /sdk/v1/vendors/findings/:id
func (h *VendorHandlers) UpdateFinding(c *gin.Context) {
tenantID := rbac.GetTenantID(c)
id := c.Param("id")
finding, err := h.store.GetFinding(c.Request.Context(), tenantID.String(), id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if finding == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "finding not found"})
return
}
var req vendor.UpdateFindingRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if req.FindingType != nil {
finding.FindingType = *req.FindingType
}
if req.Category != nil {
finding.Category = *req.Category
}
if req.Severity != nil {
finding.Severity = *req.Severity
}
if req.Title != nil {
finding.Title = *req.Title
}
if req.Description != nil {
finding.Description = *req.Description
}
if req.Recommendation != nil {
finding.Recommendation = *req.Recommendation
}
if req.Citations != nil {
finding.Citations = req.Citations
}
if req.Status != nil {
finding.Status = *req.Status
}
if req.Assignee != nil {
finding.Assignee = *req.Assignee
}
if req.DueDate != nil {
finding.DueDate = req.DueDate
}
if req.Resolution != nil {
finding.Resolution = *req.Resolution
}
if err := h.store.UpdateFinding(c.Request.Context(), finding); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"finding": finding})
}
// ResolveFinding resolves a finding with a resolution description
// POST /sdk/v1/vendors/findings/:id/resolve
func (h *VendorHandlers) ResolveFinding(c *gin.Context) {
tenantID := rbac.GetTenantID(c)
userID := rbac.GetUserID(c)
id := c.Param("id")
var req vendor.ResolveFindingRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := h.store.ResolveFinding(c.Request.Context(), tenantID.String(), id, req.Resolution, userID.String()); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "finding resolved"})
}
// ============================================================================
// Control Instance Operations
// ============================================================================
// UpsertControlInstance creates or updates a control instance
// POST /sdk/v1/vendors/controls
func (h *VendorHandlers) UpsertControlInstance(c *gin.Context) {
var req struct {
VendorID string `json:"vendor_id" binding:"required"`
ControlID string `json:"control_id" binding:"required"`
ControlDomain string `json:"control_domain"`
Status vendor.ControlStatus `json:"status" binding:"required"`
EvidenceIDs json.RawMessage `json:"evidence_ids,omitempty"`
Notes string `json:"notes,omitempty"`
NextAssessmentDate *time.Time `json:"next_assessment_date,omitempty"`
}
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)
now := time.Now().UTC()
userIDStr := userID.String()
ci := &vendor.ControlInstance{
TenantID: tenantID,
ControlID: req.ControlID,
ControlDomain: req.ControlDomain,
Status: req.Status,
EvidenceIDs: req.EvidenceIDs,
Notes: req.Notes,
LastAssessedAt: &now,
LastAssessedBy: &userIDStr,
NextAssessmentDate: req.NextAssessmentDate,
}
// Parse VendorID
vendorUUID, err := parseUUID(req.VendorID)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid vendor_id"})
return
}
ci.VendorID = vendorUUID
if err := h.store.UpsertControlInstance(c.Request.Context(), ci); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"control_instance": ci})
}
// ListControlInstances lists control instances for a vendor
// GET /sdk/v1/vendors/controls
func (h *VendorHandlers) ListControlInstances(c *gin.Context) {
vendorID := c.Query("vendor_id")
if vendorID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "vendor_id query parameter is required"})
return
}
tenantID := rbac.GetTenantID(c)
instances, err := h.store.ListControlInstances(c.Request.Context(), tenantID.String(), vendorID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"control_instances": instances,
"total": len(instances),
})
}
// ============================================================================
// Template Operations
// ============================================================================
// ListTemplates lists available templates
// GET /sdk/v1/vendors/templates
func (h *VendorHandlers) ListTemplates(c *gin.Context) {
templateType := c.DefaultQuery("type", "VENDOR")
var category, industry *string
if cat := c.Query("category"); cat != "" {
category = &cat
}
if ind := c.Query("industry"); ind != "" {
industry = &ind
}
templates, err := h.store.ListTemplates(c.Request.Context(), templateType, category, industry)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"templates": templates,
"total": len(templates),
})
}
// GetTemplate retrieves a template by its template_id string
// GET /sdk/v1/vendors/templates/:templateId
func (h *VendorHandlers) GetTemplate(c *gin.Context) {
templateID := c.Param("templateId")
if templateID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "template ID is required"})
return
}
tmpl, err := h.store.GetTemplate(c.Request.Context(), templateID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if tmpl == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "template not found"})
return
}
c.JSON(http.StatusOK, gin.H{"template": tmpl})
}
// CreateTemplate creates a custom template
// POST /sdk/v1/vendors/templates
func (h *VendorHandlers) CreateTemplate(c *gin.Context) {
var req vendor.CreateTemplateRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
tmpl := &vendor.Template{
TemplateType: req.TemplateType,
TemplateID: req.TemplateID,
Category: req.Category,
NameDE: req.NameDE,
NameEN: req.NameEN,
DescriptionDE: req.DescriptionDE,
DescriptionEN: req.DescriptionEN,
TemplateData: req.TemplateData,
Industry: req.Industry,
Tags: req.Tags,
IsSystem: req.IsSystem,
IsActive: true,
}
// Set tenant for custom (non-system) templates
if !req.IsSystem {
tid := rbac.GetTenantID(c).String()
tmpl.TenantID = &tid
}
if err := h.store.CreateTemplate(c.Request.Context(), tmpl); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, gin.H{"template": tmpl})
}
// ApplyTemplate creates a vendor from a template
// POST /sdk/v1/vendors/templates/:templateId/apply
func (h *VendorHandlers) ApplyTemplate(c *gin.Context) {
templateID := c.Param("templateId")
if templateID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "template ID is required"})
return
}
tmpl, err := h.store.GetTemplate(c.Request.Context(), templateID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if tmpl == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "template not found"})
return
}
// Parse template_data to extract suggested vendor fields
var templateData struct {
ServiceCategory string `json:"service_category"`
SuggestedRole string `json:"suggested_role"`
DataAccessLevel string `json:"data_access_level"`
ReviewFrequency string `json:"review_frequency"`
Certifications json.RawMessage `json:"certifications"`
ProcessingLocations json.RawMessage `json:"processing_locations"`
}
if err := json.Unmarshal(tmpl.TemplateData, &templateData); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to parse template data"})
return
}
// Optional overrides from request body
var overrides struct {
Name string `json:"name"`
Country string `json:"country"`
Website string `json:"website"`
ContactName string `json:"contact_name"`
ContactEmail string `json:"contact_email"`
}
c.ShouldBindJSON(&overrides)
tenantID := rbac.GetTenantID(c)
userID := rbac.GetUserID(c)
v := &vendor.Vendor{
TenantID: tenantID,
Name: overrides.Name,
Country: overrides.Country,
Website: overrides.Website,
ContactName: overrides.ContactName,
ContactEmail: overrides.ContactEmail,
Role: vendor.VendorRole(templateData.SuggestedRole),
ServiceCategory: templateData.ServiceCategory,
DataAccessLevel: templateData.DataAccessLevel,
ReviewFrequency: templateData.ReviewFrequency,
Certifications: templateData.Certifications,
ProcessingLocations: templateData.ProcessingLocations,
Status: vendor.VendorStatusActive,
TemplateID: &templateID,
CreatedBy: userID.String(),
}
if v.Name == "" {
v.Name = tmpl.NameDE
}
if v.Country == "" {
v.Country = "DE"
}
if v.Role == "" {
v.Role = vendor.VendorRoleProcessor
}
if err := h.store.CreateVendor(c.Request.Context(), v); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// Increment template usage
_ = h.store.IncrementTemplateUsage(c.Request.Context(), templateID)
c.JSON(http.StatusCreated, gin.H{
"vendor": v,
"template_id": templateID,
"message": "vendor created from template",
})
}
// ============================================================================
// Statistics
// ============================================================================
// GetStatistics returns aggregated vendor statistics
// GET /sdk/v1/vendors/stats
func (h *VendorHandlers) GetStatistics(c *gin.Context) {
tenantID := rbac.GetTenantID(c)
stats, err := h.store.GetVendorStats(c.Request.Context(), tenantID.String())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, stats)
}
// ============================================================================
// Helpers
// ============================================================================
func parseUUID(s string) (uuid.UUID, error) {
return uuid.Parse(s)
}

View File

@@ -0,0 +1,538 @@
package handlers
import (
"net/http"
"time"
"github.com/breakpilot/ai-compliance-sdk/internal/rbac"
"github.com/breakpilot/ai-compliance-sdk/internal/whistleblower"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
// WhistleblowerHandlers handles whistleblower HTTP requests
type WhistleblowerHandlers struct {
store *whistleblower.Store
}
// NewWhistleblowerHandlers creates new whistleblower handlers
func NewWhistleblowerHandlers(store *whistleblower.Store) *WhistleblowerHandlers {
return &WhistleblowerHandlers{store: store}
}
// ============================================================================
// Public Handlers (NO auth required — for anonymous reporters)
// ============================================================================
// SubmitReport handles public report submission (no auth required)
// POST /sdk/v1/whistleblower/public/submit
func (h *WhistleblowerHandlers) SubmitReport(c *gin.Context) {
var req whistleblower.PublicReportSubmission
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Get tenant ID from header or query param (public endpoint still needs tenant context)
tenantIDStr := c.GetHeader("X-Tenant-ID")
if tenantIDStr == "" {
tenantIDStr = c.Query("tenant_id")
}
if tenantIDStr == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "tenant_id is required"})
return
}
tenantID, err := uuid.Parse(tenantIDStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid tenant_id"})
return
}
report := &whistleblower.Report{
TenantID: tenantID,
Category: req.Category,
Title: req.Title,
Description: req.Description,
IsAnonymous: req.IsAnonymous,
}
// Only set reporter info if not anonymous
if !req.IsAnonymous {
report.ReporterName = req.ReporterName
report.ReporterEmail = req.ReporterEmail
report.ReporterPhone = req.ReporterPhone
}
if err := h.store.CreateReport(c.Request.Context(), report); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// Return reference number and access key (access key only shown ONCE!)
c.JSON(http.StatusCreated, whistleblower.PublicReportResponse{
ReferenceNumber: report.ReferenceNumber,
AccessKey: report.AccessKey,
})
}
// GetReportByAccessKey retrieves a report by access key (for anonymous reporters)
// GET /sdk/v1/whistleblower/public/report?access_key=xxx
func (h *WhistleblowerHandlers) GetReportByAccessKey(c *gin.Context) {
accessKey := c.Query("access_key")
if accessKey == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "access_key is required"})
return
}
report, err := h.store.GetReportByAccessKey(c.Request.Context(), accessKey)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if report == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "report not found"})
return
}
// Return limited fields for public access (no access_key, no internal details)
c.JSON(http.StatusOK, gin.H{
"reference_number": report.ReferenceNumber,
"category": report.Category,
"status": report.Status,
"title": report.Title,
"received_at": report.ReceivedAt,
"deadline_acknowledgment": report.DeadlineAcknowledgment,
"deadline_feedback": report.DeadlineFeedback,
"acknowledged_at": report.AcknowledgedAt,
"closed_at": report.ClosedAt,
})
}
// SendPublicMessage allows a reporter to send a message via access key
// POST /sdk/v1/whistleblower/public/message?access_key=xxx
func (h *WhistleblowerHandlers) SendPublicMessage(c *gin.Context) {
accessKey := c.Query("access_key")
if accessKey == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "access_key is required"})
return
}
report, err := h.store.GetReportByAccessKey(c.Request.Context(), accessKey)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if report == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "report not found"})
return
}
var req whistleblower.SendMessageRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
msg := &whistleblower.AnonymousMessage{
ReportID: report.ID,
Direction: whistleblower.MessageDirectionReporterToAdmin,
Content: req.Content,
}
if err := h.store.AddMessage(c.Request.Context(), msg); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, gin.H{"message": msg})
}
// ============================================================================
// Admin Handlers (auth required)
// ============================================================================
// ListReports lists all reports for the tenant
// GET /sdk/v1/whistleblower/reports
func (h *WhistleblowerHandlers) ListReports(c *gin.Context) {
tenantID := rbac.GetTenantID(c)
filters := &whistleblower.ReportFilters{
Limit: 50,
}
if status := c.Query("status"); status != "" {
filters.Status = whistleblower.ReportStatus(status)
}
if category := c.Query("category"); category != "" {
filters.Category = whistleblower.ReportCategory(category)
}
reports, total, err := h.store.ListReports(c.Request.Context(), tenantID, filters)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, whistleblower.ReportListResponse{
Reports: reports,
Total: total,
})
}
// GetReport retrieves a report by ID (admin)
// GET /sdk/v1/whistleblower/reports/:id
func (h *WhistleblowerHandlers) GetReport(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid report ID"})
return
}
report, err := h.store.GetReport(c.Request.Context(), id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if report == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "report not found"})
return
}
// Get messages and measures for full view
messages, _ := h.store.ListMessages(c.Request.Context(), id)
measures, _ := h.store.ListMeasures(c.Request.Context(), id)
// Do not expose access key to admin either
report.AccessKey = ""
c.JSON(http.StatusOK, gin.H{
"report": report,
"messages": messages,
"measures": measures,
})
}
// UpdateReport updates a report
// PUT /sdk/v1/whistleblower/reports/:id
func (h *WhistleblowerHandlers) UpdateReport(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid report ID"})
return
}
report, err := h.store.GetReport(c.Request.Context(), id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if report == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "report not found"})
return
}
var req whistleblower.ReportUpdateRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
userID := rbac.GetUserID(c)
if req.Category != "" {
report.Category = req.Category
}
if req.Status != "" {
report.Status = req.Status
}
if req.Title != "" {
report.Title = req.Title
}
if req.Description != "" {
report.Description = req.Description
}
if req.AssignedTo != nil {
report.AssignedTo = req.AssignedTo
}
report.AuditTrail = append(report.AuditTrail, whistleblower.AuditEntry{
Timestamp: time.Now().UTC(),
Action: "report_updated",
UserID: userID.String(),
Details: "Report updated by admin",
})
if err := h.store.UpdateReport(c.Request.Context(), report); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
report.AccessKey = ""
c.JSON(http.StatusOK, gin.H{"report": report})
}
// DeleteReport deletes a report
// DELETE /sdk/v1/whistleblower/reports/:id
func (h *WhistleblowerHandlers) DeleteReport(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid report ID"})
return
}
if err := h.store.DeleteReport(c.Request.Context(), id); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "report deleted"})
}
// AcknowledgeReport acknowledges a report (within 7-day HinSchG deadline)
// POST /sdk/v1/whistleblower/reports/:id/acknowledge
func (h *WhistleblowerHandlers) AcknowledgeReport(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid report ID"})
return
}
report, err := h.store.GetReport(c.Request.Context(), id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if report == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "report not found"})
return
}
if report.AcknowledgedAt != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "report already acknowledged"})
return
}
userID := rbac.GetUserID(c)
if err := h.store.AcknowledgeReport(c.Request.Context(), id, userID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// Optionally send acknowledgment message to reporter
var req whistleblower.AcknowledgeRequest
if err := c.ShouldBindJSON(&req); err == nil && req.Message != "" {
msg := &whistleblower.AnonymousMessage{
ReportID: id,
Direction: whistleblower.MessageDirectionAdminToReporter,
Content: req.Message,
}
h.store.AddMessage(c.Request.Context(), msg)
}
// Check if deadline was met
isOverdue := time.Now().UTC().After(report.DeadlineAcknowledgment)
c.JSON(http.StatusOK, gin.H{
"message": "report acknowledged",
"is_overdue": isOverdue,
})
}
// StartInvestigation changes the report status to investigation
// POST /sdk/v1/whistleblower/reports/:id/investigate
func (h *WhistleblowerHandlers) StartInvestigation(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid report ID"})
return
}
report, err := h.store.GetReport(c.Request.Context(), id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if report == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "report not found"})
return
}
userID := rbac.GetUserID(c)
report.Status = whistleblower.ReportStatusInvestigation
report.AuditTrail = append(report.AuditTrail, whistleblower.AuditEntry{
Timestamp: time.Now().UTC(),
Action: "investigation_started",
UserID: userID.String(),
Details: "Investigation started",
})
if err := h.store.UpdateReport(c.Request.Context(), report); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"message": "investigation started",
"report": report,
})
}
// AddMeasure adds a corrective measure to a report
// POST /sdk/v1/whistleblower/reports/:id/measures
func (h *WhistleblowerHandlers) AddMeasure(c *gin.Context) {
reportID, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid report ID"})
return
}
// Verify report exists
report, err := h.store.GetReport(c.Request.Context(), reportID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if report == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "report not found"})
return
}
var req whistleblower.AddMeasureRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
userID := rbac.GetUserID(c)
measure := &whistleblower.Measure{
ReportID: reportID,
Title: req.Title,
Description: req.Description,
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
}
// Update report status to measures_taken if not already
if report.Status != whistleblower.ReportStatusMeasuresTaken &&
report.Status != whistleblower.ReportStatusClosed {
report.Status = whistleblower.ReportStatusMeasuresTaken
report.AuditTrail = append(report.AuditTrail, whistleblower.AuditEntry{
Timestamp: time.Now().UTC(),
Action: "measure_added",
UserID: userID.String(),
Details: "Corrective measure added: " + req.Title,
})
h.store.UpdateReport(c.Request.Context(), report)
}
c.JSON(http.StatusCreated, gin.H{"measure": measure})
}
// CloseReport closes a report with a resolution
// POST /sdk/v1/whistleblower/reports/:id/close
func (h *WhistleblowerHandlers) CloseReport(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid report ID"})
return
}
var req whistleblower.CloseReportRequest
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.CloseReport(c.Request.Context(), id, userID, req.Resolution); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "report closed"})
}
// SendAdminMessage sends a message from admin to reporter
// POST /sdk/v1/whistleblower/reports/:id/messages
func (h *WhistleblowerHandlers) SendAdminMessage(c *gin.Context) {
reportID, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid report ID"})
return
}
// Verify report exists
report, err := h.store.GetReport(c.Request.Context(), reportID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if report == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "report not found"})
return
}
var req whistleblower.SendMessageRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
msg := &whistleblower.AnonymousMessage{
ReportID: reportID,
Direction: whistleblower.MessageDirectionAdminToReporter,
Content: req.Content,
}
if err := h.store.AddMessage(c.Request.Context(), msg); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, gin.H{"message": msg})
}
// ListMessages lists messages for a report
// GET /sdk/v1/whistleblower/reports/:id/messages
func (h *WhistleblowerHandlers) ListMessages(c *gin.Context) {
reportID, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid report ID"})
return
}
messages, err := h.store.ListMessages(c.Request.Context(), reportID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"messages": messages,
"total": len(messages),
})
}
// GetStatistics returns whistleblower statistics for the tenant
// GET /sdk/v1/whistleblower/statistics
func (h *WhistleblowerHandlers) 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)
}