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:
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)
|
||||
}
|
||||
Reference in New Issue
Block a user