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
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:
226
ai-compliance-sdk/internal/academy/models.go
Normal file
226
ai-compliance-sdk/internal/academy/models.go
Normal 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"`
|
||||
}
|
||||
666
ai-compliance-sdk/internal/academy/store.go
Normal file
666
ai-compliance-sdk/internal/academy/store.go
Normal 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
|
||||
}
|
||||
587
ai-compliance-sdk/internal/api/handlers/academy_handlers.go
Normal file
587
ai-compliance-sdk/internal/api/handlers/academy_handlers.go
Normal 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)
|
||||
}
|
||||
668
ai-compliance-sdk/internal/api/handlers/incidents_handlers.go
Normal file
668
ai-compliance-sdk/internal/api/handlers/incidents_handlers.go
Normal 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)
|
||||
}
|
||||
|
||||
850
ai-compliance-sdk/internal/api/handlers/vendor_handlers.go
Normal file
850
ai-compliance-sdk/internal/api/handlers/vendor_handlers.go
Normal 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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
305
ai-compliance-sdk/internal/incidents/models.go
Normal file
305
ai-compliance-sdk/internal/incidents/models.go
Normal 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"
|
||||
}
|
||||
571
ai-compliance-sdk/internal/incidents/store.go
Normal file
571
ai-compliance-sdk/internal/incidents/store.go
Normal 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
|
||||
}
|
||||
242
ai-compliance-sdk/internal/whistleblower/models.go
Normal file
242
ai-compliance-sdk/internal/whistleblower/models.go
Normal 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)
|
||||
}
|
||||
591
ai-compliance-sdk/internal/whistleblower/store.go
Normal file
591
ai-compliance-sdk/internal/whistleblower/store.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user