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

@@ -15,8 +15,12 @@ import (
"github.com/breakpilot/ai-compliance-sdk/internal/dsgvo"
"github.com/breakpilot/ai-compliance-sdk/internal/llm"
"github.com/breakpilot/ai-compliance-sdk/internal/rbac"
"github.com/breakpilot/ai-compliance-sdk/internal/academy"
"github.com/breakpilot/ai-compliance-sdk/internal/incidents"
"github.com/breakpilot/ai-compliance-sdk/internal/roadmap"
"github.com/breakpilot/ai-compliance-sdk/internal/ucca"
"github.com/breakpilot/ai-compliance-sdk/internal/whistleblower"
"github.com/breakpilot/ai-compliance-sdk/internal/vendor"
"github.com/breakpilot/ai-compliance-sdk/internal/workshop"
"github.com/breakpilot/ai-compliance-sdk/internal/portfolio"
"github.com/gin-contrib/cors"
@@ -59,6 +63,10 @@ func main() {
roadmapStore := roadmap.NewStore(pool)
workshopStore := workshop.NewStore(pool)
portfolioStore := portfolio.NewStore(pool)
academyStore := academy.NewStore(pool)
whistleblowerStore := whistleblower.NewStore(pool)
incidentStore := incidents.NewStore(pool)
vendorStore := vendor.NewStore(pool)
// Initialize services
rbacService := rbac.NewService(rbacStore)
@@ -98,6 +106,10 @@ func main() {
workshopHandlers := handlers.NewWorkshopHandlers(workshopStore)
portfolioHandlers := handlers.NewPortfolioHandlers(portfolioStore)
draftingHandlers := handlers.NewDraftingHandlers(accessGate, providerRegistry, piiDetector, auditStore, trailBuilder)
academyHandlers := handlers.NewAcademyHandlers(academyStore)
whistleblowerHandlers := handlers.NewWhistleblowerHandlers(whistleblowerStore)
incidentHandlers := handlers.NewIncidentHandlers(incidentStore)
vendorHandlers := handlers.NewVendorHandlers(vendorStore)
// Initialize middleware
rbacMiddleware := rbac.NewMiddleware(rbacService, policyEngine)
@@ -435,6 +447,129 @@ func main() {
draftingRoutes.POST("/validate", draftingHandlers.ValidateDocument)
draftingRoutes.GET("/history", draftingHandlers.GetDraftHistory)
}
// Academy routes - E-Learning / Compliance Training
academyRoutes := v1.Group("/academy")
{
// Courses
academyRoutes.POST("/courses", academyHandlers.CreateCourse)
academyRoutes.GET("/courses", academyHandlers.ListCourses)
academyRoutes.GET("/courses/:id", academyHandlers.GetCourse)
academyRoutes.PUT("/courses/:id", academyHandlers.UpdateCourse)
academyRoutes.DELETE("/courses/:id", academyHandlers.DeleteCourse)
// Enrollments
academyRoutes.POST("/enrollments", academyHandlers.CreateEnrollment)
academyRoutes.GET("/enrollments", academyHandlers.ListEnrollments)
academyRoutes.PUT("/enrollments/:id/progress", academyHandlers.UpdateProgress)
academyRoutes.POST("/enrollments/:id/complete", academyHandlers.CompleteEnrollment)
// Certificates
academyRoutes.GET("/certificates/:id", academyHandlers.GetCertificate)
academyRoutes.POST("/enrollments/:id/certificate", academyHandlers.GenerateCertificate)
// Quiz
academyRoutes.POST("/courses/:id/quiz", academyHandlers.SubmitQuiz)
// Statistics
academyRoutes.GET("/stats", academyHandlers.GetStatistics)
}
// Whistleblower routes - Hinweisgebersystem (HinSchG)
whistleblowerRoutes := v1.Group("/whistleblower")
{
// Public endpoints (anonymous reporting)
whistleblowerRoutes.POST("/reports/submit", whistleblowerHandlers.SubmitReport)
whistleblowerRoutes.GET("/reports/access/:accessKey", whistleblowerHandlers.GetReportByAccessKey)
whistleblowerRoutes.POST("/reports/access/:accessKey/messages", whistleblowerHandlers.SendPublicMessage)
// Admin endpoints
whistleblowerRoutes.GET("/reports", whistleblowerHandlers.ListReports)
whistleblowerRoutes.GET("/reports/:id", whistleblowerHandlers.GetReport)
whistleblowerRoutes.PUT("/reports/:id", whistleblowerHandlers.UpdateReport)
whistleblowerRoutes.DELETE("/reports/:id", whistleblowerHandlers.DeleteReport)
whistleblowerRoutes.POST("/reports/:id/acknowledge", whistleblowerHandlers.AcknowledgeReport)
whistleblowerRoutes.POST("/reports/:id/investigate", whistleblowerHandlers.StartInvestigation)
whistleblowerRoutes.POST("/reports/:id/measures", whistleblowerHandlers.AddMeasure)
whistleblowerRoutes.POST("/reports/:id/close", whistleblowerHandlers.CloseReport)
whistleblowerRoutes.POST("/reports/:id/messages", whistleblowerHandlers.SendAdminMessage)
whistleblowerRoutes.GET("/reports/:id/messages", whistleblowerHandlers.ListMessages)
// Statistics
whistleblowerRoutes.GET("/stats", whistleblowerHandlers.GetStatistics)
}
// Incidents routes - Datenpannen-Management (DSGVO Art. 33/34)
incidentRoutes := v1.Group("/incidents")
{
// Incident CRUD
incidentRoutes.POST("", incidentHandlers.CreateIncident)
incidentRoutes.GET("", incidentHandlers.ListIncidents)
incidentRoutes.GET("/:id", incidentHandlers.GetIncident)
incidentRoutes.PUT("/:id", incidentHandlers.UpdateIncident)
incidentRoutes.DELETE("/:id", incidentHandlers.DeleteIncident)
// Risk Assessment
incidentRoutes.POST("/:id/assess-risk", incidentHandlers.AssessRisk)
// Authority Notification (Art. 33)
incidentRoutes.POST("/:id/notify-authority", incidentHandlers.SubmitAuthorityNotification)
// Data Subject Notification (Art. 34)
incidentRoutes.POST("/:id/notify-subjects", incidentHandlers.NotifyDataSubjects)
// Measures
incidentRoutes.POST("/:id/measures", incidentHandlers.AddMeasure)
incidentRoutes.PUT("/:id/measures/:measureId", incidentHandlers.UpdateMeasure)
incidentRoutes.POST("/:id/measures/:measureId/complete", incidentHandlers.CompleteMeasure)
// Timeline
incidentRoutes.POST("/:id/timeline", incidentHandlers.AddTimelineEntry)
// Lifecycle
incidentRoutes.POST("/:id/close", incidentHandlers.CloseIncident)
// Statistics
incidentRoutes.GET("/stats", incidentHandlers.GetStatistics)
}
// Vendor Compliance routes - Vendor Management & AVV/DPA (DSGVO Art. 28)
vendorRoutes := v1.Group("/vendors")
{
// Vendor CRUD
vendorRoutes.POST("", vendorHandlers.CreateVendor)
vendorRoutes.GET("", vendorHandlers.ListVendors)
vendorRoutes.GET("/:id", vendorHandlers.GetVendor)
vendorRoutes.PUT("/:id", vendorHandlers.UpdateVendor)
vendorRoutes.DELETE("/:id", vendorHandlers.DeleteVendor)
// Contracts (AVV/DPA)
vendorRoutes.POST("/contracts", vendorHandlers.CreateContract)
vendorRoutes.GET("/contracts", vendorHandlers.ListContracts)
vendorRoutes.GET("/contracts/:id", vendorHandlers.GetContract)
vendorRoutes.PUT("/contracts/:id", vendorHandlers.UpdateContract)
vendorRoutes.DELETE("/contracts/:id", vendorHandlers.DeleteContract)
// Findings
vendorRoutes.POST("/findings", vendorHandlers.CreateFinding)
vendorRoutes.GET("/findings", vendorHandlers.ListFindings)
vendorRoutes.GET("/findings/:id", vendorHandlers.GetFinding)
vendorRoutes.PUT("/findings/:id", vendorHandlers.UpdateFinding)
vendorRoutes.POST("/findings/:id/resolve", vendorHandlers.ResolveFinding)
// Control Instances
vendorRoutes.POST("/controls", vendorHandlers.UpsertControlInstance)
vendorRoutes.GET("/controls", vendorHandlers.ListControlInstances)
// Templates
vendorRoutes.GET("/templates", vendorHandlers.ListTemplates)
vendorRoutes.GET("/templates/:templateId", vendorHandlers.GetTemplate)
vendorRoutes.POST("/templates", vendorHandlers.CreateTemplate)
vendorRoutes.POST("/templates/:templateId/apply", vendorHandlers.ApplyTemplate)
// Statistics
vendorRoutes.GET("/stats", vendorHandlers.GetStatistics)
}
}
// Create HTTP server

View File

@@ -0,0 +1,226 @@
package academy
import (
"time"
"github.com/google/uuid"
)
// ============================================================================
// Constants / Enums
// ============================================================================
// CourseCategory represents the category of a compliance course
type CourseCategory string
const (
CourseCategoryDSGVOBasics CourseCategory = "dsgvo_basics"
CourseCategoryITSecurity CourseCategory = "it_security"
CourseCategoryAILiteracy CourseCategory = "ai_literacy"
CourseCategoryWhistleblowerProtection CourseCategory = "whistleblower_protection"
CourseCategoryCustom CourseCategory = "custom"
)
// EnrollmentStatus represents the status of an enrollment
type EnrollmentStatus string
const (
EnrollmentStatusNotStarted EnrollmentStatus = "not_started"
EnrollmentStatusInProgress EnrollmentStatus = "in_progress"
EnrollmentStatusCompleted EnrollmentStatus = "completed"
EnrollmentStatusExpired EnrollmentStatus = "expired"
)
// LessonType represents the type of a lesson
type LessonType string
const (
LessonTypeVideo LessonType = "video"
LessonTypeText LessonType = "text"
LessonTypeQuiz LessonType = "quiz"
LessonTypeInteractive LessonType = "interactive"
)
// ============================================================================
// Main Entities
// ============================================================================
// Course represents a compliance training course
type Course struct {
ID uuid.UUID `json:"id"`
TenantID uuid.UUID `json:"tenant_id"`
Title string `json:"title"`
Description string `json:"description,omitempty"`
Category CourseCategory `json:"category"`
DurationMinutes int `json:"duration_minutes"`
RequiredForRoles []string `json:"required_for_roles"` // JSONB in DB
Lessons []Lesson `json:"lessons,omitempty"`
IsActive bool `json:"is_active"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// Lesson represents a single lesson within a course
type Lesson struct {
ID uuid.UUID `json:"id"`
CourseID uuid.UUID `json:"course_id"`
Title string `json:"title"`
Description string `json:"description,omitempty"`
LessonType LessonType `json:"lesson_type"`
ContentURL string `json:"content_url,omitempty"`
DurationMinutes int `json:"duration_minutes"`
OrderIndex int `json:"order_index"`
QuizQuestions []QuizQuestion `json:"quiz_questions,omitempty"` // JSONB in DB
}
// QuizQuestion represents a single quiz question embedded in a lesson
type QuizQuestion struct {
Question string `json:"question"`
Options []string `json:"options"`
CorrectIndex int `json:"correct_index"`
Explanation string `json:"explanation"`
}
// Enrollment represents a user's enrollment in a course
type Enrollment struct {
ID uuid.UUID `json:"id"`
TenantID uuid.UUID `json:"tenant_id"`
CourseID uuid.UUID `json:"course_id"`
UserID uuid.UUID `json:"user_id"`
UserName string `json:"user_name"`
UserEmail string `json:"user_email"`
Status EnrollmentStatus `json:"status"`
ProgressPercent int `json:"progress_percent"`
CurrentLessonIndex int `json:"current_lesson_index"`
StartedAt *time.Time `json:"started_at,omitempty"`
CompletedAt *time.Time `json:"completed_at,omitempty"`
Deadline *time.Time `json:"deadline,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// Certificate represents a completion certificate for an enrollment
type Certificate struct {
ID uuid.UUID `json:"id"`
EnrollmentID uuid.UUID `json:"enrollment_id"`
UserName string `json:"user_name"`
CourseTitle string `json:"course_title"`
IssuedAt time.Time `json:"issued_at"`
ValidUntil *time.Time `json:"valid_until,omitempty"`
PDFURL string `json:"pdf_url,omitempty"`
}
// AcademyStatistics contains aggregated academy metrics
type AcademyStatistics struct {
TotalCourses int `json:"total_courses"`
TotalEnrollments int `json:"total_enrollments"`
CompletionRate float64 `json:"completion_rate"` // 0-100
OverdueCount int `json:"overdue_count"`
AvgCompletionDays float64 `json:"avg_completion_days"`
}
// ============================================================================
// Filter Types
// ============================================================================
// CourseFilters defines filters for listing courses
type CourseFilters struct {
Category CourseCategory
IsActive *bool
Search string
Limit int
Offset int
}
// EnrollmentFilters defines filters for listing enrollments
type EnrollmentFilters struct {
CourseID *uuid.UUID
UserID *uuid.UUID
Status EnrollmentStatus
Limit int
Offset int
}
// ============================================================================
// API Request/Response Types
// ============================================================================
// CreateCourseRequest is the API request for creating a course
type CreateCourseRequest struct {
Title string `json:"title" binding:"required"`
Description string `json:"description,omitempty"`
Category CourseCategory `json:"category" binding:"required"`
DurationMinutes int `json:"duration_minutes"`
RequiredForRoles []string `json:"required_for_roles,omitempty"`
Lessons []CreateLessonRequest `json:"lessons,omitempty"`
}
// CreateLessonRequest is the API request for creating a lesson
type CreateLessonRequest struct {
Title string `json:"title" binding:"required"`
Description string `json:"description,omitempty"`
LessonType LessonType `json:"lesson_type" binding:"required"`
ContentURL string `json:"content_url,omitempty"`
DurationMinutes int `json:"duration_minutes"`
OrderIndex int `json:"order_index"`
QuizQuestions []QuizQuestion `json:"quiz_questions,omitempty"`
}
// UpdateCourseRequest is the API request for updating a course
type UpdateCourseRequest struct {
Title *string `json:"title,omitempty"`
Description *string `json:"description,omitempty"`
Category *CourseCategory `json:"category,omitempty"`
DurationMinutes *int `json:"duration_minutes,omitempty"`
RequiredForRoles []string `json:"required_for_roles,omitempty"`
IsActive *bool `json:"is_active,omitempty"`
}
// EnrollUserRequest is the API request for enrolling a user in a course
type EnrollUserRequest struct {
CourseID uuid.UUID `json:"course_id" binding:"required"`
UserID uuid.UUID `json:"user_id" binding:"required"`
UserName string `json:"user_name" binding:"required"`
UserEmail string `json:"user_email" binding:"required"`
Deadline *time.Time `json:"deadline,omitempty"`
}
// UpdateProgressRequest is the API request for updating enrollment progress
type UpdateProgressRequest struct {
Progress int `json:"progress" binding:"required"`
CurrentLesson int `json:"current_lesson"`
}
// SubmitQuizRequest is the API request for submitting quiz answers
type SubmitQuizRequest struct {
LessonID uuid.UUID `json:"lesson_id" binding:"required"`
Answers []int `json:"answers" binding:"required"` // Index of selected answer per question
}
// SubmitQuizResponse is the API response for quiz submission
type SubmitQuizResponse struct {
Score int `json:"score"` // 0-100
Passed bool `json:"passed"`
CorrectAnswers int `json:"correct_answers"`
TotalQuestions int `json:"total_questions"`
Results []QuizResult `json:"results"`
}
// QuizResult represents the result for a single quiz question
type QuizResult struct {
Question string `json:"question"`
Correct bool `json:"correct"`
Explanation string `json:"explanation"`
}
// CourseListResponse is the API response for listing courses
type CourseListResponse struct {
Courses []Course `json:"courses"`
Total int `json:"total"`
}
// EnrollmentListResponse is the API response for listing enrollments
type EnrollmentListResponse struct {
Enrollments []Enrollment `json:"enrollments"`
Total int `json:"total"`
}

View File

@@ -0,0 +1,666 @@
package academy
import (
"context"
"encoding/json"
"fmt"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
// Store handles academy data persistence
type Store struct {
pool *pgxpool.Pool
}
// NewStore creates a new academy store
func NewStore(pool *pgxpool.Pool) *Store {
return &Store{pool: pool}
}
// ============================================================================
// Course CRUD Operations
// ============================================================================
// CreateCourse creates a new course
func (s *Store) CreateCourse(ctx context.Context, course *Course) error {
course.ID = uuid.New()
course.CreatedAt = time.Now().UTC()
course.UpdatedAt = course.CreatedAt
if !course.IsActive {
course.IsActive = true
}
requiredForRoles, _ := json.Marshal(course.RequiredForRoles)
_, err := s.pool.Exec(ctx, `
INSERT INTO academy_courses (
id, tenant_id, title, description, category,
duration_minutes, required_for_roles, is_active,
created_at, updated_at
) VALUES (
$1, $2, $3, $4, $5,
$6, $7, $8,
$9, $10
)
`,
course.ID, course.TenantID, course.Title, course.Description, string(course.Category),
course.DurationMinutes, requiredForRoles, course.IsActive,
course.CreatedAt, course.UpdatedAt,
)
return err
}
// GetCourse retrieves a course by ID
func (s *Store) GetCourse(ctx context.Context, id uuid.UUID) (*Course, error) {
var course Course
var category string
var requiredForRoles []byte
err := s.pool.QueryRow(ctx, `
SELECT
id, tenant_id, title, description, category,
duration_minutes, required_for_roles, is_active,
created_at, updated_at
FROM academy_courses WHERE id = $1
`, id).Scan(
&course.ID, &course.TenantID, &course.Title, &course.Description, &category,
&course.DurationMinutes, &requiredForRoles, &course.IsActive,
&course.CreatedAt, &course.UpdatedAt,
)
if err == pgx.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
course.Category = CourseCategory(category)
json.Unmarshal(requiredForRoles, &course.RequiredForRoles)
if course.RequiredForRoles == nil {
course.RequiredForRoles = []string{}
}
// Load lessons for this course
lessons, err := s.ListLessons(ctx, course.ID)
if err != nil {
return nil, err
}
course.Lessons = lessons
return &course, nil
}
// ListCourses lists courses for a tenant with optional filters
func (s *Store) ListCourses(ctx context.Context, tenantID uuid.UUID, filters *CourseFilters) ([]Course, int, error) {
// Count query
countQuery := "SELECT COUNT(*) FROM academy_courses WHERE tenant_id = $1"
countArgs := []interface{}{tenantID}
countArgIdx := 2
// List query
query := `
SELECT
id, tenant_id, title, description, category,
duration_minutes, required_for_roles, is_active,
created_at, updated_at
FROM academy_courses WHERE tenant_id = $1`
args := []interface{}{tenantID}
argIdx := 2
if filters != nil {
if filters.Category != "" {
query += fmt.Sprintf(" AND category = $%d", argIdx)
args = append(args, string(filters.Category))
argIdx++
countQuery += fmt.Sprintf(" AND category = $%d", countArgIdx)
countArgs = append(countArgs, string(filters.Category))
countArgIdx++
}
if filters.IsActive != nil {
query += fmt.Sprintf(" AND is_active = $%d", argIdx)
args = append(args, *filters.IsActive)
argIdx++
countQuery += fmt.Sprintf(" AND is_active = $%d", countArgIdx)
countArgs = append(countArgs, *filters.IsActive)
countArgIdx++
}
if filters.Search != "" {
query += fmt.Sprintf(" AND (title ILIKE $%d OR description ILIKE $%d)", argIdx, argIdx)
args = append(args, "%"+filters.Search+"%")
argIdx++
countQuery += fmt.Sprintf(" AND (title ILIKE $%d OR description ILIKE $%d)", countArgIdx, countArgIdx)
countArgs = append(countArgs, "%"+filters.Search+"%")
countArgIdx++
}
}
// Get total count
var total int
err := s.pool.QueryRow(ctx, countQuery, countArgs...).Scan(&total)
if err != nil {
return nil, 0, err
}
query += " ORDER BY created_at DESC"
if filters != nil && filters.Limit > 0 {
query += fmt.Sprintf(" LIMIT $%d", argIdx)
args = append(args, filters.Limit)
argIdx++
if filters.Offset > 0 {
query += fmt.Sprintf(" OFFSET $%d", argIdx)
args = append(args, filters.Offset)
argIdx++
}
}
rows, err := s.pool.Query(ctx, query, args...)
if err != nil {
return nil, 0, err
}
defer rows.Close()
var courses []Course
for rows.Next() {
var course Course
var category string
var requiredForRoles []byte
err := rows.Scan(
&course.ID, &course.TenantID, &course.Title, &course.Description, &category,
&course.DurationMinutes, &requiredForRoles, &course.IsActive,
&course.CreatedAt, &course.UpdatedAt,
)
if err != nil {
return nil, 0, err
}
course.Category = CourseCategory(category)
json.Unmarshal(requiredForRoles, &course.RequiredForRoles)
if course.RequiredForRoles == nil {
course.RequiredForRoles = []string{}
}
courses = append(courses, course)
}
if courses == nil {
courses = []Course{}
}
return courses, total, nil
}
// UpdateCourse updates a course
func (s *Store) UpdateCourse(ctx context.Context, course *Course) error {
course.UpdatedAt = time.Now().UTC()
requiredForRoles, _ := json.Marshal(course.RequiredForRoles)
_, err := s.pool.Exec(ctx, `
UPDATE academy_courses SET
title = $2, description = $3, category = $4,
duration_minutes = $5, required_for_roles = $6, is_active = $7,
updated_at = $8
WHERE id = $1
`,
course.ID, course.Title, course.Description, string(course.Category),
course.DurationMinutes, requiredForRoles, course.IsActive,
course.UpdatedAt,
)
return err
}
// DeleteCourse deletes a course and its related data (via CASCADE)
func (s *Store) DeleteCourse(ctx context.Context, id uuid.UUID) error {
_, err := s.pool.Exec(ctx, "DELETE FROM academy_courses WHERE id = $1", id)
return err
}
// ============================================================================
// Lesson Operations
// ============================================================================
// CreateLesson creates a new lesson
func (s *Store) CreateLesson(ctx context.Context, lesson *Lesson) error {
lesson.ID = uuid.New()
quizQuestions, _ := json.Marshal(lesson.QuizQuestions)
_, err := s.pool.Exec(ctx, `
INSERT INTO academy_lessons (
id, course_id, title, description, lesson_type,
content_url, duration_minutes, order_index, quiz_questions
) VALUES (
$1, $2, $3, $4, $5,
$6, $7, $8, $9
)
`,
lesson.ID, lesson.CourseID, lesson.Title, lesson.Description, string(lesson.LessonType),
lesson.ContentURL, lesson.DurationMinutes, lesson.OrderIndex, quizQuestions,
)
return err
}
// ListLessons lists lessons for a course ordered by order_index
func (s *Store) ListLessons(ctx context.Context, courseID uuid.UUID) ([]Lesson, error) {
rows, err := s.pool.Query(ctx, `
SELECT
id, course_id, title, description, lesson_type,
content_url, duration_minutes, order_index, quiz_questions
FROM academy_lessons WHERE course_id = $1
ORDER BY order_index ASC
`, courseID)
if err != nil {
return nil, err
}
defer rows.Close()
var lessons []Lesson
for rows.Next() {
var lesson Lesson
var lessonType string
var quizQuestions []byte
err := rows.Scan(
&lesson.ID, &lesson.CourseID, &lesson.Title, &lesson.Description, &lessonType,
&lesson.ContentURL, &lesson.DurationMinutes, &lesson.OrderIndex, &quizQuestions,
)
if err != nil {
return nil, err
}
lesson.LessonType = LessonType(lessonType)
json.Unmarshal(quizQuestions, &lesson.QuizQuestions)
if lesson.QuizQuestions == nil {
lesson.QuizQuestions = []QuizQuestion{}
}
lessons = append(lessons, lesson)
}
if lessons == nil {
lessons = []Lesson{}
}
return lessons, nil
}
// GetLesson retrieves a single lesson by ID
func (s *Store) GetLesson(ctx context.Context, id uuid.UUID) (*Lesson, error) {
var lesson Lesson
var lessonType string
var quizQuestions []byte
err := s.pool.QueryRow(ctx, `
SELECT
id, course_id, title, description, lesson_type,
content_url, duration_minutes, order_index, quiz_questions
FROM academy_lessons WHERE id = $1
`, id).Scan(
&lesson.ID, &lesson.CourseID, &lesson.Title, &lesson.Description, &lessonType,
&lesson.ContentURL, &lesson.DurationMinutes, &lesson.OrderIndex, &quizQuestions,
)
if err == pgx.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
lesson.LessonType = LessonType(lessonType)
json.Unmarshal(quizQuestions, &lesson.QuizQuestions)
if lesson.QuizQuestions == nil {
lesson.QuizQuestions = []QuizQuestion{}
}
return &lesson, nil
}
// ============================================================================
// Enrollment Operations
// ============================================================================
// CreateEnrollment creates a new enrollment
func (s *Store) CreateEnrollment(ctx context.Context, enrollment *Enrollment) error {
enrollment.ID = uuid.New()
enrollment.CreatedAt = time.Now().UTC()
enrollment.UpdatedAt = enrollment.CreatedAt
if enrollment.Status == "" {
enrollment.Status = EnrollmentStatusNotStarted
}
_, err := s.pool.Exec(ctx, `
INSERT INTO academy_enrollments (
id, tenant_id, course_id, user_id, user_name, user_email,
status, progress_percent, current_lesson_index,
started_at, completed_at, deadline,
created_at, updated_at
) VALUES (
$1, $2, $3, $4, $5, $6,
$7, $8, $9,
$10, $11, $12,
$13, $14
)
`,
enrollment.ID, enrollment.TenantID, enrollment.CourseID, enrollment.UserID, enrollment.UserName, enrollment.UserEmail,
string(enrollment.Status), enrollment.ProgressPercent, enrollment.CurrentLessonIndex,
enrollment.StartedAt, enrollment.CompletedAt, enrollment.Deadline,
enrollment.CreatedAt, enrollment.UpdatedAt,
)
return err
}
// GetEnrollment retrieves an enrollment by ID
func (s *Store) GetEnrollment(ctx context.Context, id uuid.UUID) (*Enrollment, error) {
var enrollment Enrollment
var status string
err := s.pool.QueryRow(ctx, `
SELECT
id, tenant_id, course_id, user_id, user_name, user_email,
status, progress_percent, current_lesson_index,
started_at, completed_at, deadline,
created_at, updated_at
FROM academy_enrollments WHERE id = $1
`, id).Scan(
&enrollment.ID, &enrollment.TenantID, &enrollment.CourseID, &enrollment.UserID, &enrollment.UserName, &enrollment.UserEmail,
&status, &enrollment.ProgressPercent, &enrollment.CurrentLessonIndex,
&enrollment.StartedAt, &enrollment.CompletedAt, &enrollment.Deadline,
&enrollment.CreatedAt, &enrollment.UpdatedAt,
)
if err == pgx.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
enrollment.Status = EnrollmentStatus(status)
return &enrollment, nil
}
// ListEnrollments lists enrollments for a tenant with optional filters
func (s *Store) ListEnrollments(ctx context.Context, tenantID uuid.UUID, filters *EnrollmentFilters) ([]Enrollment, int, error) {
// Count query
countQuery := "SELECT COUNT(*) FROM academy_enrollments WHERE tenant_id = $1"
countArgs := []interface{}{tenantID}
countArgIdx := 2
// List query
query := `
SELECT
id, tenant_id, course_id, user_id, user_name, user_email,
status, progress_percent, current_lesson_index,
started_at, completed_at, deadline,
created_at, updated_at
FROM academy_enrollments WHERE tenant_id = $1`
args := []interface{}{tenantID}
argIdx := 2
if filters != nil {
if filters.CourseID != nil {
query += fmt.Sprintf(" AND course_id = $%d", argIdx)
args = append(args, *filters.CourseID)
argIdx++
countQuery += fmt.Sprintf(" AND course_id = $%d", countArgIdx)
countArgs = append(countArgs, *filters.CourseID)
countArgIdx++
}
if filters.UserID != nil {
query += fmt.Sprintf(" AND user_id = $%d", argIdx)
args = append(args, *filters.UserID)
argIdx++
countQuery += fmt.Sprintf(" AND user_id = $%d", countArgIdx)
countArgs = append(countArgs, *filters.UserID)
countArgIdx++
}
if filters.Status != "" {
query += fmt.Sprintf(" AND status = $%d", argIdx)
args = append(args, string(filters.Status))
argIdx++
countQuery += fmt.Sprintf(" AND status = $%d", countArgIdx)
countArgs = append(countArgs, string(filters.Status))
countArgIdx++
}
}
// Get total count
var total int
err := s.pool.QueryRow(ctx, countQuery, countArgs...).Scan(&total)
if err != nil {
return nil, 0, err
}
query += " ORDER BY created_at DESC"
if filters != nil && filters.Limit > 0 {
query += fmt.Sprintf(" LIMIT $%d", argIdx)
args = append(args, filters.Limit)
argIdx++
if filters.Offset > 0 {
query += fmt.Sprintf(" OFFSET $%d", argIdx)
args = append(args, filters.Offset)
argIdx++
}
}
rows, err := s.pool.Query(ctx, query, args...)
if err != nil {
return nil, 0, err
}
defer rows.Close()
var enrollments []Enrollment
for rows.Next() {
var enrollment Enrollment
var status string
err := rows.Scan(
&enrollment.ID, &enrollment.TenantID, &enrollment.CourseID, &enrollment.UserID, &enrollment.UserName, &enrollment.UserEmail,
&status, &enrollment.ProgressPercent, &enrollment.CurrentLessonIndex,
&enrollment.StartedAt, &enrollment.CompletedAt, &enrollment.Deadline,
&enrollment.CreatedAt, &enrollment.UpdatedAt,
)
if err != nil {
return nil, 0, err
}
enrollment.Status = EnrollmentStatus(status)
enrollments = append(enrollments, enrollment)
}
if enrollments == nil {
enrollments = []Enrollment{}
}
return enrollments, total, nil
}
// UpdateEnrollmentProgress updates the progress for an enrollment
func (s *Store) UpdateEnrollmentProgress(ctx context.Context, id uuid.UUID, progress int, currentLesson int) error {
now := time.Now().UTC()
// If progress > 0, set started_at if not already set and update status to in_progress
_, err := s.pool.Exec(ctx, `
UPDATE academy_enrollments SET
progress_percent = $2,
current_lesson_index = $3,
status = CASE
WHEN $2 >= 100 THEN 'completed'
WHEN $2 > 0 THEN 'in_progress'
ELSE status
END,
started_at = CASE
WHEN started_at IS NULL AND $2 > 0 THEN $4
ELSE started_at
END,
completed_at = CASE
WHEN $2 >= 100 THEN $4
ELSE completed_at
END,
updated_at = $4
WHERE id = $1
`, id, progress, currentLesson, now)
return err
}
// CompleteEnrollment marks an enrollment as completed
func (s *Store) CompleteEnrollment(ctx context.Context, id uuid.UUID) error {
now := time.Now().UTC()
_, err := s.pool.Exec(ctx, `
UPDATE academy_enrollments SET
status = 'completed',
progress_percent = 100,
completed_at = $2,
updated_at = $2
WHERE id = $1
`, id, now)
return err
}
// ============================================================================
// Certificate Operations
// ============================================================================
// GetCertificate retrieves a certificate by ID
func (s *Store) GetCertificate(ctx context.Context, id uuid.UUID) (*Certificate, error) {
var cert Certificate
err := s.pool.QueryRow(ctx, `
SELECT
id, enrollment_id, user_name, course_title,
issued_at, valid_until, pdf_url
FROM academy_certificates WHERE id = $1
`, id).Scan(
&cert.ID, &cert.EnrollmentID, &cert.UserName, &cert.CourseTitle,
&cert.IssuedAt, &cert.ValidUntil, &cert.PDFURL,
)
if err == pgx.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
return &cert, nil
}
// GetCertificateByEnrollment retrieves a certificate by enrollment ID
func (s *Store) GetCertificateByEnrollment(ctx context.Context, enrollmentID uuid.UUID) (*Certificate, error) {
var cert Certificate
err := s.pool.QueryRow(ctx, `
SELECT
id, enrollment_id, user_name, course_title,
issued_at, valid_until, pdf_url
FROM academy_certificates WHERE enrollment_id = $1
`, enrollmentID).Scan(
&cert.ID, &cert.EnrollmentID, &cert.UserName, &cert.CourseTitle,
&cert.IssuedAt, &cert.ValidUntil, &cert.PDFURL,
)
if err == pgx.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
return &cert, nil
}
// CreateCertificate creates a new certificate
func (s *Store) CreateCertificate(ctx context.Context, cert *Certificate) error {
cert.ID = uuid.New()
cert.IssuedAt = time.Now().UTC()
_, err := s.pool.Exec(ctx, `
INSERT INTO academy_certificates (
id, enrollment_id, user_name, course_title,
issued_at, valid_until, pdf_url
) VALUES (
$1, $2, $3, $4,
$5, $6, $7
)
`,
cert.ID, cert.EnrollmentID, cert.UserName, cert.CourseTitle,
cert.IssuedAt, cert.ValidUntil, cert.PDFURL,
)
return err
}
// ============================================================================
// Statistics
// ============================================================================
// GetStatistics returns aggregated academy statistics for a tenant
func (s *Store) GetStatistics(ctx context.Context, tenantID uuid.UUID) (*AcademyStatistics, error) {
stats := &AcademyStatistics{}
// Total active courses
s.pool.QueryRow(ctx,
"SELECT COUNT(*) FROM academy_courses WHERE tenant_id = $1 AND is_active = true",
tenantID).Scan(&stats.TotalCourses)
// Total enrollments
s.pool.QueryRow(ctx,
"SELECT COUNT(*) FROM academy_enrollments WHERE tenant_id = $1",
tenantID).Scan(&stats.TotalEnrollments)
// Completion rate
if stats.TotalEnrollments > 0 {
var completed int
s.pool.QueryRow(ctx,
"SELECT COUNT(*) FROM academy_enrollments WHERE tenant_id = $1 AND status = 'completed'",
tenantID).Scan(&completed)
stats.CompletionRate = float64(completed) / float64(stats.TotalEnrollments) * 100
}
// Overdue count (past deadline, not completed)
s.pool.QueryRow(ctx,
`SELECT COUNT(*) FROM academy_enrollments
WHERE tenant_id = $1
AND status NOT IN ('completed', 'expired')
AND deadline IS NOT NULL
AND deadline < NOW()`,
tenantID).Scan(&stats.OverdueCount)
// Average completion days
s.pool.QueryRow(ctx,
`SELECT COALESCE(AVG(EXTRACT(EPOCH FROM (completed_at - started_at)) / 86400), 0)
FROM academy_enrollments
WHERE tenant_id = $1
AND status = 'completed'
AND started_at IS NOT NULL
AND completed_at IS NOT NULL`,
tenantID).Scan(&stats.AvgCompletionDays)
return stats, nil
}

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)
}

View File

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

View File

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

View File

@@ -0,0 +1,242 @@
package whistleblower
import (
"crypto/rand"
"fmt"
"time"
"github.com/google/uuid"
)
// ============================================================================
// Constants / Enums
// ============================================================================
// ReportCategory represents the category of a whistleblower report
type ReportCategory string
const (
ReportCategoryCorruption ReportCategory = "corruption"
ReportCategoryFraud ReportCategory = "fraud"
ReportCategoryDataProtection ReportCategory = "data_protection"
ReportCategoryDiscrimination ReportCategory = "discrimination"
ReportCategoryEnvironment ReportCategory = "environment"
ReportCategoryCompetition ReportCategory = "competition"
ReportCategoryProductSafety ReportCategory = "product_safety"
ReportCategoryTaxEvasion ReportCategory = "tax_evasion"
ReportCategoryOther ReportCategory = "other"
)
// ReportStatus represents the status of a whistleblower report
type ReportStatus string
const (
ReportStatusNew ReportStatus = "new"
ReportStatusAcknowledged ReportStatus = "acknowledged"
ReportStatusUnderReview ReportStatus = "under_review"
ReportStatusInvestigation ReportStatus = "investigation"
ReportStatusMeasuresTaken ReportStatus = "measures_taken"
ReportStatusClosed ReportStatus = "closed"
ReportStatusRejected ReportStatus = "rejected"
)
// MessageDirection represents the direction of an anonymous message
type MessageDirection string
const (
MessageDirectionReporterToAdmin MessageDirection = "reporter_to_admin"
MessageDirectionAdminToReporter MessageDirection = "admin_to_reporter"
)
// MeasureStatus represents the status of a corrective measure
type MeasureStatus string
const (
MeasureStatusPlanned MeasureStatus = "planned"
MeasureStatusInProgress MeasureStatus = "in_progress"
MeasureStatusCompleted MeasureStatus = "completed"
)
// ============================================================================
// Main Entities
// ============================================================================
// Report represents a whistleblower report (Hinweis) per HinSchG
type Report struct {
ID uuid.UUID `json:"id"`
TenantID uuid.UUID `json:"tenant_id"`
ReferenceNumber string `json:"reference_number"` // e.g. "WB-2026-0001"
AccessKey string `json:"access_key,omitempty"` // for anonymous access, only returned once
// Report content
Category ReportCategory `json:"category"`
Status ReportStatus `json:"status"`
Title string `json:"title"`
Description string `json:"description"`
// Reporter info (optional, for non-anonymous reports)
IsAnonymous bool `json:"is_anonymous"`
ReporterName *string `json:"reporter_name,omitempty"`
ReporterEmail *string `json:"reporter_email,omitempty"`
ReporterPhone *string `json:"reporter_phone,omitempty"`
// HinSchG deadlines
ReceivedAt time.Time `json:"received_at"`
DeadlineAcknowledgment time.Time `json:"deadline_acknowledgment"` // 7 days from received_at per HinSchG
DeadlineFeedback time.Time `json:"deadline_feedback"` // 3 months from received_at per HinSchG
// Status timestamps
AcknowledgedAt *time.Time `json:"acknowledged_at,omitempty"`
ClosedAt *time.Time `json:"closed_at,omitempty"`
// Assignment
AssignedTo *uuid.UUID `json:"assigned_to,omitempty"`
// Resolution
Resolution string `json:"resolution,omitempty"`
// Audit trail (stored as JSONB)
AuditTrail []AuditEntry `json:"audit_trail"`
// Timestamps
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// AnonymousMessage represents a message exchanged between reporter and admin
type AnonymousMessage struct {
ID uuid.UUID `json:"id"`
ReportID uuid.UUID `json:"report_id"`
Direction MessageDirection `json:"direction"`
Content string `json:"content"`
SentAt time.Time `json:"sent_at"`
ReadAt *time.Time `json:"read_at,omitempty"`
}
// Measure represents a corrective measure taken for a report
type Measure struct {
ID uuid.UUID `json:"id"`
ReportID uuid.UUID `json:"report_id"`
Title string `json:"title"`
Description string `json:"description"`
Status MeasureStatus `json:"status"`
Responsible string `json:"responsible"`
DueDate *time.Time `json:"due_date,omitempty"`
CompletedAt *time.Time `json:"completed_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
// AuditEntry represents an entry in the audit trail
type AuditEntry struct {
Timestamp time.Time `json:"timestamp"`
Action string `json:"action"`
UserID string `json:"user_id"`
Details string `json:"details"`
}
// WhistleblowerStatistics contains aggregated statistics for a tenant
type WhistleblowerStatistics struct {
TotalReports int `json:"total_reports"`
ByStatus map[string]int `json:"by_status"`
ByCategory map[string]int `json:"by_category"`
OverdueAcknowledgments int `json:"overdue_acknowledgments"`
OverdueFeedbacks int `json:"overdue_feedbacks"`
AvgResolutionDays float64 `json:"avg_resolution_days"`
}
// ============================================================================
// API Request/Response Types
// ============================================================================
// PublicReportSubmission is the request for submitting a report (NO auth required)
type PublicReportSubmission struct {
Category ReportCategory `json:"category" binding:"required"`
Title string `json:"title" binding:"required"`
Description string `json:"description" binding:"required"`
IsAnonymous bool `json:"is_anonymous"`
ReporterName *string `json:"reporter_name,omitempty"`
ReporterEmail *string `json:"reporter_email,omitempty"`
ReporterPhone *string `json:"reporter_phone,omitempty"`
}
// PublicReportResponse is returned after submitting a report (access_key only shown once!)
type PublicReportResponse struct {
ReferenceNumber string `json:"reference_number"`
AccessKey string `json:"access_key"`
}
// ReportUpdateRequest is the request for updating a report (admin)
type ReportUpdateRequest struct {
Category ReportCategory `json:"category,omitempty"`
Status ReportStatus `json:"status,omitempty"`
Title string `json:"title,omitempty"`
Description string `json:"description,omitempty"`
AssignedTo *uuid.UUID `json:"assigned_to,omitempty"`
}
// AcknowledgeRequest is the request for acknowledging a report
type AcknowledgeRequest struct {
Message string `json:"message,omitempty"` // optional acknowledgment message to reporter
}
// CloseReportRequest is the request for closing a report
type CloseReportRequest struct {
Resolution string `json:"resolution" binding:"required"`
}
// AddMeasureRequest is the request for adding a corrective measure
type AddMeasureRequest struct {
Title string `json:"title" binding:"required"`
Description string `json:"description"`
Responsible string `json:"responsible" binding:"required"`
DueDate *time.Time `json:"due_date,omitempty"`
}
// UpdateMeasureRequest is the request for updating a measure
type UpdateMeasureRequest struct {
Title string `json:"title,omitempty"`
Description string `json:"description,omitempty"`
Status MeasureStatus `json:"status,omitempty"`
Responsible string `json:"responsible,omitempty"`
DueDate *time.Time `json:"due_date,omitempty"`
}
// SendMessageRequest is the request for sending an anonymous message
type SendMessageRequest struct {
Content string `json:"content" binding:"required"`
}
// ReportListResponse is the response for listing reports
type ReportListResponse struct {
Reports []Report `json:"reports"`
Total int `json:"total"`
}
// ReportFilters defines filters for listing reports
type ReportFilters struct {
Status ReportStatus
Category ReportCategory
Limit int
Offset int
}
// ============================================================================
// Helper Functions
// ============================================================================
// generateAccessKey generates a random 12-character alphanumeric key
func generateAccessKey() string {
const charset = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
b := make([]byte, 12)
randomBytes := make([]byte, 12)
rand.Read(randomBytes)
for i := range b {
b[i] = charset[int(randomBytes[i])%len(charset)]
}
return string(b)
}
// generateReferenceNumber generates a reference number like "WB-2026-0042"
func generateReferenceNumber(year int, sequence int) string {
return fmt.Sprintf("WB-%d-%04d", year, sequence)
}

View File

@@ -0,0 +1,591 @@
package whistleblower
import (
"context"
"encoding/json"
"fmt"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
// Store handles whistleblower data persistence
type Store struct {
pool *pgxpool.Pool
}
// NewStore creates a new whistleblower store
func NewStore(pool *pgxpool.Pool) *Store {
return &Store{pool: pool}
}
// ============================================================================
// Report CRUD Operations
// ============================================================================
// CreateReport creates a new whistleblower report with auto-generated reference number and access key
func (s *Store) CreateReport(ctx context.Context, report *Report) error {
report.ID = uuid.New()
now := time.Now().UTC()
report.CreatedAt = now
report.UpdatedAt = now
report.ReceivedAt = now
report.DeadlineAcknowledgment = now.AddDate(0, 0, 7) // 7 days per HinSchG
report.DeadlineFeedback = now.AddDate(0, 3, 0) // 3 months per HinSchG
if report.Status == "" {
report.Status = ReportStatusNew
}
// Generate access key
report.AccessKey = generateAccessKey()
// Generate reference number
year := now.Year()
seq, err := s.GetNextSequenceNumber(ctx, report.TenantID, year)
if err != nil {
return fmt.Errorf("failed to get sequence number: %w", err)
}
report.ReferenceNumber = generateReferenceNumber(year, seq)
// Initialize audit trail
if report.AuditTrail == nil {
report.AuditTrail = []AuditEntry{}
}
report.AuditTrail = append(report.AuditTrail, AuditEntry{
Timestamp: now,
Action: "report_created",
UserID: "system",
Details: "Report submitted",
})
auditTrailJSON, _ := json.Marshal(report.AuditTrail)
_, err = s.pool.Exec(ctx, `
INSERT INTO whistleblower_reports (
id, tenant_id, reference_number, access_key,
category, status, title, description,
is_anonymous, reporter_name, reporter_email, reporter_phone,
received_at, deadline_acknowledgment, deadline_feedback,
acknowledged_at, closed_at, assigned_to,
audit_trail, resolution,
created_at, updated_at
) VALUES (
$1, $2, $3, $4,
$5, $6, $7, $8,
$9, $10, $11, $12,
$13, $14, $15,
$16, $17, $18,
$19, $20,
$21, $22
)
`,
report.ID, report.TenantID, report.ReferenceNumber, report.AccessKey,
string(report.Category), string(report.Status), report.Title, report.Description,
report.IsAnonymous, report.ReporterName, report.ReporterEmail, report.ReporterPhone,
report.ReceivedAt, report.DeadlineAcknowledgment, report.DeadlineFeedback,
report.AcknowledgedAt, report.ClosedAt, report.AssignedTo,
auditTrailJSON, report.Resolution,
report.CreatedAt, report.UpdatedAt,
)
return err
}
// GetReport retrieves a report by ID
func (s *Store) GetReport(ctx context.Context, id uuid.UUID) (*Report, error) {
var report Report
var category, status string
var auditTrailJSON []byte
err := s.pool.QueryRow(ctx, `
SELECT
id, tenant_id, reference_number, access_key,
category, status, title, description,
is_anonymous, reporter_name, reporter_email, reporter_phone,
received_at, deadline_acknowledgment, deadline_feedback,
acknowledged_at, closed_at, assigned_to,
audit_trail, resolution,
created_at, updated_at
FROM whistleblower_reports WHERE id = $1
`, id).Scan(
&report.ID, &report.TenantID, &report.ReferenceNumber, &report.AccessKey,
&category, &status, &report.Title, &report.Description,
&report.IsAnonymous, &report.ReporterName, &report.ReporterEmail, &report.ReporterPhone,
&report.ReceivedAt, &report.DeadlineAcknowledgment, &report.DeadlineFeedback,
&report.AcknowledgedAt, &report.ClosedAt, &report.AssignedTo,
&auditTrailJSON, &report.Resolution,
&report.CreatedAt, &report.UpdatedAt,
)
if err == pgx.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
report.Category = ReportCategory(category)
report.Status = ReportStatus(status)
json.Unmarshal(auditTrailJSON, &report.AuditTrail)
return &report, nil
}
// GetReportByAccessKey retrieves a report by its access key (for public anonymous access)
func (s *Store) GetReportByAccessKey(ctx context.Context, accessKey string) (*Report, error) {
var id uuid.UUID
err := s.pool.QueryRow(ctx,
"SELECT id FROM whistleblower_reports WHERE access_key = $1",
accessKey,
).Scan(&id)
if err == pgx.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
return s.GetReport(ctx, id)
}
// ListReports lists reports for a tenant with optional filters
func (s *Store) ListReports(ctx context.Context, tenantID uuid.UUID, filters *ReportFilters) ([]Report, int, error) {
// Count total
countQuery := "SELECT COUNT(*) FROM whistleblower_reports WHERE tenant_id = $1"
countArgs := []interface{}{tenantID}
countArgIdx := 2
if filters != nil {
if filters.Status != "" {
countQuery += fmt.Sprintf(" AND status = $%d", countArgIdx)
countArgs = append(countArgs, string(filters.Status))
countArgIdx++
}
if filters.Category != "" {
countQuery += fmt.Sprintf(" AND category = $%d", countArgIdx)
countArgs = append(countArgs, string(filters.Category))
countArgIdx++
}
}
var total int
err := s.pool.QueryRow(ctx, countQuery, countArgs...).Scan(&total)
if err != nil {
return nil, 0, err
}
// Build data query
query := `
SELECT
id, tenant_id, reference_number, access_key,
category, status, title, description,
is_anonymous, reporter_name, reporter_email, reporter_phone,
received_at, deadline_acknowledgment, deadline_feedback,
acknowledged_at, closed_at, assigned_to,
audit_trail, resolution,
created_at, updated_at
FROM whistleblower_reports WHERE tenant_id = $1`
args := []interface{}{tenantID}
argIdx := 2
if filters != nil {
if filters.Status != "" {
query += fmt.Sprintf(" AND status = $%d", argIdx)
args = append(args, string(filters.Status))
argIdx++
}
if filters.Category != "" {
query += fmt.Sprintf(" AND category = $%d", argIdx)
args = append(args, string(filters.Category))
argIdx++
}
}
query += " ORDER BY created_at DESC"
if filters != nil && filters.Limit > 0 {
query += fmt.Sprintf(" LIMIT $%d", argIdx)
args = append(args, filters.Limit)
argIdx++
if filters.Offset > 0 {
query += fmt.Sprintf(" OFFSET $%d", argIdx)
args = append(args, filters.Offset)
argIdx++
}
}
rows, err := s.pool.Query(ctx, query, args...)
if err != nil {
return nil, 0, err
}
defer rows.Close()
var reports []Report
for rows.Next() {
var report Report
var category, status string
var auditTrailJSON []byte
err := rows.Scan(
&report.ID, &report.TenantID, &report.ReferenceNumber, &report.AccessKey,
&category, &status, &report.Title, &report.Description,
&report.IsAnonymous, &report.ReporterName, &report.ReporterEmail, &report.ReporterPhone,
&report.ReceivedAt, &report.DeadlineAcknowledgment, &report.DeadlineFeedback,
&report.AcknowledgedAt, &report.ClosedAt, &report.AssignedTo,
&auditTrailJSON, &report.Resolution,
&report.CreatedAt, &report.UpdatedAt,
)
if err != nil {
return nil, 0, err
}
report.Category = ReportCategory(category)
report.Status = ReportStatus(status)
json.Unmarshal(auditTrailJSON, &report.AuditTrail)
// Do not expose access key in list responses
report.AccessKey = ""
reports = append(reports, report)
}
return reports, total, nil
}
// UpdateReport updates a report
func (s *Store) UpdateReport(ctx context.Context, report *Report) error {
report.UpdatedAt = time.Now().UTC()
auditTrailJSON, _ := json.Marshal(report.AuditTrail)
_, err := s.pool.Exec(ctx, `
UPDATE whistleblower_reports SET
category = $2, status = $3, title = $4, description = $5,
assigned_to = $6, audit_trail = $7, resolution = $8,
updated_at = $9
WHERE id = $1
`,
report.ID,
string(report.Category), string(report.Status), report.Title, report.Description,
report.AssignedTo, auditTrailJSON, report.Resolution,
report.UpdatedAt,
)
return err
}
// AcknowledgeReport acknowledges a report, setting acknowledged_at and adding an audit entry
func (s *Store) AcknowledgeReport(ctx context.Context, id uuid.UUID, userID uuid.UUID) error {
report, err := s.GetReport(ctx, id)
if err != nil || report == nil {
return fmt.Errorf("report not found")
}
now := time.Now().UTC()
report.AcknowledgedAt = &now
report.Status = ReportStatusAcknowledged
report.UpdatedAt = now
report.AuditTrail = append(report.AuditTrail, AuditEntry{
Timestamp: now,
Action: "report_acknowledged",
UserID: userID.String(),
Details: "Report acknowledged within HinSchG deadline",
})
auditTrailJSON, _ := json.Marshal(report.AuditTrail)
_, err = s.pool.Exec(ctx, `
UPDATE whistleblower_reports SET
status = $2, acknowledged_at = $3,
audit_trail = $4, updated_at = $5
WHERE id = $1
`,
id, string(ReportStatusAcknowledged), now,
auditTrailJSON, now,
)
return err
}
// CloseReport closes a report with a resolution
func (s *Store) CloseReport(ctx context.Context, id uuid.UUID, userID uuid.UUID, resolution string) error {
report, err := s.GetReport(ctx, id)
if err != nil || report == nil {
return fmt.Errorf("report not found")
}
now := time.Now().UTC()
report.ClosedAt = &now
report.Status = ReportStatusClosed
report.Resolution = resolution
report.UpdatedAt = now
report.AuditTrail = append(report.AuditTrail, AuditEntry{
Timestamp: now,
Action: "report_closed",
UserID: userID.String(),
Details: "Report closed with resolution: " + resolution,
})
auditTrailJSON, _ := json.Marshal(report.AuditTrail)
_, err = s.pool.Exec(ctx, `
UPDATE whistleblower_reports SET
status = $2, closed_at = $3, resolution = $4,
audit_trail = $5, updated_at = $6
WHERE id = $1
`,
id, string(ReportStatusClosed), now, resolution,
auditTrailJSON, now,
)
return err
}
// DeleteReport deletes a report and its related data (cascading via FK)
func (s *Store) DeleteReport(ctx context.Context, id uuid.UUID) error {
_, err := s.pool.Exec(ctx, "DELETE FROM whistleblower_measures WHERE report_id = $1", id)
if err != nil {
return err
}
_, err = s.pool.Exec(ctx, "DELETE FROM whistleblower_messages WHERE report_id = $1", id)
if err != nil {
return err
}
_, err = s.pool.Exec(ctx, "DELETE FROM whistleblower_reports WHERE id = $1", id)
return err
}
// ============================================================================
// Message Operations
// ============================================================================
// AddMessage adds an anonymous message to a report
func (s *Store) AddMessage(ctx context.Context, msg *AnonymousMessage) error {
msg.ID = uuid.New()
msg.SentAt = time.Now().UTC()
_, err := s.pool.Exec(ctx, `
INSERT INTO whistleblower_messages (
id, report_id, direction, content, sent_at, read_at
) VALUES (
$1, $2, $3, $4, $5, $6
)
`,
msg.ID, msg.ReportID, string(msg.Direction), msg.Content, msg.SentAt, msg.ReadAt,
)
return err
}
// ListMessages lists messages for a report
func (s *Store) ListMessages(ctx context.Context, reportID uuid.UUID) ([]AnonymousMessage, error) {
rows, err := s.pool.Query(ctx, `
SELECT
id, report_id, direction, content, sent_at, read_at
FROM whistleblower_messages WHERE report_id = $1
ORDER BY sent_at ASC
`, reportID)
if err != nil {
return nil, err
}
defer rows.Close()
var messages []AnonymousMessage
for rows.Next() {
var msg AnonymousMessage
var direction string
err := rows.Scan(
&msg.ID, &msg.ReportID, &direction, &msg.Content, &msg.SentAt, &msg.ReadAt,
)
if err != nil {
return nil, err
}
msg.Direction = MessageDirection(direction)
messages = append(messages, msg)
}
return messages, nil
}
// ============================================================================
// Measure Operations
// ============================================================================
// AddMeasure adds a corrective measure to a report
func (s *Store) AddMeasure(ctx context.Context, measure *Measure) error {
measure.ID = uuid.New()
measure.CreatedAt = time.Now().UTC()
if measure.Status == "" {
measure.Status = MeasureStatusPlanned
}
_, err := s.pool.Exec(ctx, `
INSERT INTO whistleblower_measures (
id, report_id, title, description, status,
responsible, due_date, completed_at, created_at
) VALUES (
$1, $2, $3, $4, $5,
$6, $7, $8, $9
)
`,
measure.ID, measure.ReportID, measure.Title, measure.Description, string(measure.Status),
measure.Responsible, measure.DueDate, measure.CompletedAt, measure.CreatedAt,
)
return err
}
// ListMeasures lists measures for a report
func (s *Store) ListMeasures(ctx context.Context, reportID uuid.UUID) ([]Measure, error) {
rows, err := s.pool.Query(ctx, `
SELECT
id, report_id, title, description, status,
responsible, due_date, completed_at, created_at
FROM whistleblower_measures WHERE report_id = $1
ORDER BY created_at ASC
`, reportID)
if err != nil {
return nil, err
}
defer rows.Close()
var measures []Measure
for rows.Next() {
var m Measure
var status string
err := rows.Scan(
&m.ID, &m.ReportID, &m.Title, &m.Description, &status,
&m.Responsible, &m.DueDate, &m.CompletedAt, &m.CreatedAt,
)
if err != nil {
return nil, err
}
m.Status = MeasureStatus(status)
measures = append(measures, m)
}
return measures, nil
}
// UpdateMeasure updates a measure
func (s *Store) UpdateMeasure(ctx context.Context, measure *Measure) error {
_, err := s.pool.Exec(ctx, `
UPDATE whistleblower_measures SET
title = $2, description = $3, status = $4,
responsible = $5, due_date = $6, completed_at = $7
WHERE id = $1
`,
measure.ID,
measure.Title, measure.Description, string(measure.Status),
measure.Responsible, measure.DueDate, measure.CompletedAt,
)
return err
}
// ============================================================================
// Statistics
// ============================================================================
// GetStatistics returns aggregated whistleblower statistics for a tenant
func (s *Store) GetStatistics(ctx context.Context, tenantID uuid.UUID) (*WhistleblowerStatistics, error) {
stats := &WhistleblowerStatistics{
ByStatus: make(map[string]int),
ByCategory: make(map[string]int),
}
// Total reports
s.pool.QueryRow(ctx,
"SELECT COUNT(*) FROM whistleblower_reports WHERE tenant_id = $1",
tenantID).Scan(&stats.TotalReports)
// By status
rows, err := s.pool.Query(ctx,
"SELECT status, COUNT(*) FROM whistleblower_reports WHERE tenant_id = $1 GROUP BY status",
tenantID)
if err == nil {
defer rows.Close()
for rows.Next() {
var status string
var count int
rows.Scan(&status, &count)
stats.ByStatus[status] = count
}
}
// By category
rows, err = s.pool.Query(ctx,
"SELECT category, COUNT(*) FROM whistleblower_reports WHERE tenant_id = $1 GROUP BY category",
tenantID)
if err == nil {
defer rows.Close()
for rows.Next() {
var category string
var count int
rows.Scan(&category, &count)
stats.ByCategory[category] = count
}
}
// Overdue acknowledgments: reports past deadline_acknowledgment that haven't been acknowledged
s.pool.QueryRow(ctx, `
SELECT COUNT(*) FROM whistleblower_reports
WHERE tenant_id = $1
AND acknowledged_at IS NULL
AND status = 'new'
AND deadline_acknowledgment < NOW()
`, tenantID).Scan(&stats.OverdueAcknowledgments)
// Overdue feedbacks: reports past deadline_feedback that are still open
s.pool.QueryRow(ctx, `
SELECT COUNT(*) FROM whistleblower_reports
WHERE tenant_id = $1
AND closed_at IS NULL
AND status NOT IN ('closed', 'rejected')
AND deadline_feedback < NOW()
`, tenantID).Scan(&stats.OverdueFeedbacks)
// Average resolution days (for closed reports)
s.pool.QueryRow(ctx, `
SELECT COALESCE(AVG(EXTRACT(EPOCH FROM (closed_at - received_at)) / 86400), 0)
FROM whistleblower_reports
WHERE tenant_id = $1 AND closed_at IS NOT NULL
`, tenantID).Scan(&stats.AvgResolutionDays)
return stats, nil
}
// ============================================================================
// Sequence Number
// ============================================================================
// GetNextSequenceNumber gets and increments the sequence number for reference number generation
func (s *Store) GetNextSequenceNumber(ctx context.Context, tenantID uuid.UUID, year int) (int, error) {
var seq int
err := s.pool.QueryRow(ctx, `
INSERT INTO whistleblower_sequences (tenant_id, year, last_sequence)
VALUES ($1, $2, 1)
ON CONFLICT (tenant_id, year) DO UPDATE SET
last_sequence = whistleblower_sequences.last_sequence + 1
RETURNING last_sequence
`, tenantID, year).Scan(&seq)
if err != nil {
return 0, err
}
return seq, nil
}