feat: Add Academy, Whistleblower, Incidents, Vendor, DSB, SSO, Reporting, Multi-Tenant and Industry backends
Go handlers, models, stores and migrations for all SDK modules. Updates developer portal navigation and BYOEH page. 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)
|
||||
}
|
||||
451
ai-compliance-sdk/internal/api/handlers/dsb_handlers.go
Normal file
451
ai-compliance-sdk/internal/api/handlers/dsb_handlers.go
Normal file
@@ -0,0 +1,451 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/breakpilot/ai-compliance-sdk/internal/dsb"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// DSBHandlers handles DSB-as-a-Service portal HTTP requests.
|
||||
type DSBHandlers struct {
|
||||
store *dsb.Store
|
||||
}
|
||||
|
||||
// NewDSBHandlers creates new DSB handlers.
|
||||
func NewDSBHandlers(store *dsb.Store) *DSBHandlers {
|
||||
return &DSBHandlers{store: store}
|
||||
}
|
||||
|
||||
// getDSBUserID extracts and parses the X-User-ID header as UUID.
|
||||
func getDSBUserID(c *gin.Context) (uuid.UUID, bool) {
|
||||
userIDStr := c.GetHeader("X-User-ID")
|
||||
if userIDStr == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "X-User-ID header is required"})
|
||||
return uuid.Nil, false
|
||||
}
|
||||
userID, err := uuid.Parse(userIDStr)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid X-User-ID header: must be a valid UUID"})
|
||||
return uuid.Nil, false
|
||||
}
|
||||
return userID, true
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Dashboard
|
||||
// ============================================================================
|
||||
|
||||
// GetDashboard returns the aggregated DSB dashboard.
|
||||
// GET /sdk/v1/dsb/dashboard
|
||||
func (h *DSBHandlers) GetDashboard(c *gin.Context) {
|
||||
dsbUserID, ok := getDSBUserID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
dashboard, err := h.store.GetDashboard(c.Request.Context(), dsbUserID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, dashboard)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Assignments
|
||||
// ============================================================================
|
||||
|
||||
// CreateAssignment creates a new DSB-to-tenant assignment.
|
||||
// POST /sdk/v1/dsb/assignments
|
||||
func (h *DSBHandlers) CreateAssignment(c *gin.Context) {
|
||||
var req dsb.CreateAssignmentRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
assignment := &dsb.Assignment{
|
||||
DSBUserID: req.DSBUserID,
|
||||
TenantID: req.TenantID,
|
||||
Status: req.Status,
|
||||
ContractStart: req.ContractStart,
|
||||
ContractEnd: req.ContractEnd,
|
||||
MonthlyHoursBudget: req.MonthlyHoursBudget,
|
||||
Notes: req.Notes,
|
||||
}
|
||||
|
||||
if err := h.store.CreateAssignment(c.Request.Context(), assignment); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, gin.H{"assignment": assignment})
|
||||
}
|
||||
|
||||
// ListAssignments returns all assignments for the authenticated DSB user.
|
||||
// GET /sdk/v1/dsb/assignments
|
||||
func (h *DSBHandlers) ListAssignments(c *gin.Context) {
|
||||
dsbUserID, ok := getDSBUserID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
assignments, err := h.store.ListAssignments(c.Request.Context(), dsbUserID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"assignments": assignments,
|
||||
"total": len(assignments),
|
||||
})
|
||||
}
|
||||
|
||||
// GetAssignment retrieves a single assignment by ID.
|
||||
// GET /sdk/v1/dsb/assignments/:id
|
||||
func (h *DSBHandlers) GetAssignment(c *gin.Context) {
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid assignment ID"})
|
||||
return
|
||||
}
|
||||
|
||||
assignment, err := h.store.GetAssignment(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "assignment not found"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"assignment": assignment})
|
||||
}
|
||||
|
||||
// UpdateAssignment updates an existing assignment.
|
||||
// PUT /sdk/v1/dsb/assignments/:id
|
||||
func (h *DSBHandlers) UpdateAssignment(c *gin.Context) {
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid assignment ID"})
|
||||
return
|
||||
}
|
||||
|
||||
assignment, err := h.store.GetAssignment(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "assignment not found"})
|
||||
return
|
||||
}
|
||||
|
||||
var req dsb.UpdateAssignmentRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Apply non-nil fields
|
||||
if req.Status != nil {
|
||||
assignment.Status = *req.Status
|
||||
}
|
||||
if req.ContractEnd != nil {
|
||||
assignment.ContractEnd = req.ContractEnd
|
||||
}
|
||||
if req.MonthlyHoursBudget != nil {
|
||||
assignment.MonthlyHoursBudget = *req.MonthlyHoursBudget
|
||||
}
|
||||
if req.Notes != nil {
|
||||
assignment.Notes = *req.Notes
|
||||
}
|
||||
|
||||
if err := h.store.UpdateAssignment(c.Request.Context(), assignment); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"assignment": assignment})
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Hours
|
||||
// ============================================================================
|
||||
|
||||
// CreateHourEntry creates a new time tracking entry for an assignment.
|
||||
// POST /sdk/v1/dsb/assignments/:id/hours
|
||||
func (h *DSBHandlers) CreateHourEntry(c *gin.Context) {
|
||||
assignmentID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid assignment ID"})
|
||||
return
|
||||
}
|
||||
|
||||
var req dsb.CreateHourEntryRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
billable := true
|
||||
if req.Billable != nil {
|
||||
billable = *req.Billable
|
||||
}
|
||||
|
||||
entry := &dsb.HourEntry{
|
||||
AssignmentID: assignmentID,
|
||||
Date: req.Date,
|
||||
Hours: req.Hours,
|
||||
Category: req.Category,
|
||||
Description: req.Description,
|
||||
Billable: billable,
|
||||
}
|
||||
|
||||
if err := h.store.CreateHourEntry(c.Request.Context(), entry); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, gin.H{"hour_entry": entry})
|
||||
}
|
||||
|
||||
// ListHours returns time entries for an assignment.
|
||||
// GET /sdk/v1/dsb/assignments/:id/hours?month=YYYY-MM
|
||||
func (h *DSBHandlers) ListHours(c *gin.Context) {
|
||||
assignmentID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid assignment ID"})
|
||||
return
|
||||
}
|
||||
|
||||
month := c.Query("month")
|
||||
|
||||
entries, err := h.store.ListHours(c.Request.Context(), assignmentID, month)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"hours": entries,
|
||||
"total": len(entries),
|
||||
})
|
||||
}
|
||||
|
||||
// GetHoursSummary returns aggregated hour statistics for an assignment.
|
||||
// GET /sdk/v1/dsb/assignments/:id/hours/summary?month=YYYY-MM
|
||||
func (h *DSBHandlers) GetHoursSummary(c *gin.Context) {
|
||||
assignmentID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid assignment ID"})
|
||||
return
|
||||
}
|
||||
|
||||
month := c.Query("month")
|
||||
|
||||
summary, err := h.store.GetHoursSummary(c.Request.Context(), assignmentID, month)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, summary)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Tasks
|
||||
// ============================================================================
|
||||
|
||||
// CreateTask creates a new task for an assignment.
|
||||
// POST /sdk/v1/dsb/assignments/:id/tasks
|
||||
func (h *DSBHandlers) CreateTask(c *gin.Context) {
|
||||
assignmentID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid assignment ID"})
|
||||
return
|
||||
}
|
||||
|
||||
var req dsb.CreateTaskRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
task := &dsb.Task{
|
||||
AssignmentID: assignmentID,
|
||||
Title: req.Title,
|
||||
Description: req.Description,
|
||||
Category: req.Category,
|
||||
Priority: req.Priority,
|
||||
DueDate: req.DueDate,
|
||||
}
|
||||
|
||||
if err := h.store.CreateTask(c.Request.Context(), task); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, gin.H{"task": task})
|
||||
}
|
||||
|
||||
// ListTasks returns tasks for an assignment.
|
||||
// GET /sdk/v1/dsb/assignments/:id/tasks?status=open
|
||||
func (h *DSBHandlers) ListTasks(c *gin.Context) {
|
||||
assignmentID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid assignment ID"})
|
||||
return
|
||||
}
|
||||
|
||||
status := c.Query("status")
|
||||
|
||||
tasks, err := h.store.ListTasks(c.Request.Context(), assignmentID, status)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"tasks": tasks,
|
||||
"total": len(tasks),
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateTask updates an existing task.
|
||||
// PUT /sdk/v1/dsb/tasks/:taskId
|
||||
func (h *DSBHandlers) UpdateTask(c *gin.Context) {
|
||||
taskID, err := uuid.Parse(c.Param("taskId"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid task ID"})
|
||||
return
|
||||
}
|
||||
|
||||
// We need to fetch the existing task first. Since tasks belong to assignments,
|
||||
// we query by task ID directly. For now, we do a lightweight approach: bind the
|
||||
// update request and apply changes via store.
|
||||
var req dsb.UpdateTaskRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Fetch current task by querying all tasks and filtering. Since we don't have
|
||||
// a GetTask(taskID) method, we build the task from partial data and update.
|
||||
// The store UpdateTask uses the task ID to locate the row.
|
||||
task := &dsb.Task{ID: taskID}
|
||||
|
||||
// We need to get the current values to apply partial updates correctly.
|
||||
// Query the task directly.
|
||||
row := h.store.Pool().QueryRow(c.Request.Context(), `
|
||||
SELECT id, assignment_id, title, description, category, priority, status, due_date, completed_at, created_at, updated_at
|
||||
FROM dsb_tasks WHERE id = $1
|
||||
`, taskID)
|
||||
|
||||
if err := row.Scan(
|
||||
&task.ID, &task.AssignmentID, &task.Title, &task.Description,
|
||||
&task.Category, &task.Priority, &task.Status, &task.DueDate,
|
||||
&task.CompletedAt, &task.CreatedAt, &task.UpdatedAt,
|
||||
); err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "task not found"})
|
||||
return
|
||||
}
|
||||
|
||||
// Apply non-nil fields
|
||||
if req.Title != nil {
|
||||
task.Title = *req.Title
|
||||
}
|
||||
if req.Description != nil {
|
||||
task.Description = *req.Description
|
||||
}
|
||||
if req.Category != nil {
|
||||
task.Category = *req.Category
|
||||
}
|
||||
if req.Priority != nil {
|
||||
task.Priority = *req.Priority
|
||||
}
|
||||
if req.Status != nil {
|
||||
task.Status = *req.Status
|
||||
}
|
||||
if req.DueDate != nil {
|
||||
task.DueDate = req.DueDate
|
||||
}
|
||||
|
||||
if err := h.store.UpdateTask(c.Request.Context(), task); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"task": task})
|
||||
}
|
||||
|
||||
// CompleteTask marks a task as completed.
|
||||
// POST /sdk/v1/dsb/tasks/:taskId/complete
|
||||
func (h *DSBHandlers) CompleteTask(c *gin.Context) {
|
||||
taskID, err := uuid.Parse(c.Param("taskId"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid task ID"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.store.CompleteTask(c.Request.Context(), taskID); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "task completed"})
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Communications
|
||||
// ============================================================================
|
||||
|
||||
// CreateCommunication creates a new communication log entry.
|
||||
// POST /sdk/v1/dsb/assignments/:id/communications
|
||||
func (h *DSBHandlers) CreateCommunication(c *gin.Context) {
|
||||
assignmentID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid assignment ID"})
|
||||
return
|
||||
}
|
||||
|
||||
var req dsb.CreateCommunicationRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
comm := &dsb.Communication{
|
||||
AssignmentID: assignmentID,
|
||||
Direction: req.Direction,
|
||||
Channel: req.Channel,
|
||||
Subject: req.Subject,
|
||||
Content: req.Content,
|
||||
Participants: req.Participants,
|
||||
}
|
||||
|
||||
if err := h.store.CreateCommunication(c.Request.Context(), comm); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, gin.H{"communication": comm})
|
||||
}
|
||||
|
||||
// ListCommunications returns all communications for an assignment.
|
||||
// GET /sdk/v1/dsb/assignments/:id/communications
|
||||
func (h *DSBHandlers) ListCommunications(c *gin.Context) {
|
||||
assignmentID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid assignment ID"})
|
||||
return
|
||||
}
|
||||
|
||||
comms, err := h.store.ListCommunications(c.Request.Context(), assignmentID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"communications": comms,
|
||||
"total": len(comms),
|
||||
})
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
115
ai-compliance-sdk/internal/api/handlers/industry_handlers.go
Normal file
115
ai-compliance-sdk/internal/api/handlers/industry_handlers.go
Normal file
@@ -0,0 +1,115 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/breakpilot/ai-compliance-sdk/internal/industry"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// IndustryHandlers handles industry-specific compliance template requests.
|
||||
// All data is static (embedded Go structs), so no store/database is needed.
|
||||
type IndustryHandlers struct{}
|
||||
|
||||
// NewIndustryHandlers creates new industry handlers
|
||||
func NewIndustryHandlers() *IndustryHandlers {
|
||||
return &IndustryHandlers{}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Industry Template Endpoints
|
||||
// ============================================================================
|
||||
|
||||
// ListIndustries returns a summary list of all available industry templates.
|
||||
// GET /sdk/v1/industries
|
||||
func (h *IndustryHandlers) ListIndustries(c *gin.Context) {
|
||||
templates := industry.GetAllTemplates()
|
||||
|
||||
summaries := make([]industry.IndustrySummary, 0, len(templates))
|
||||
for _, t := range templates {
|
||||
summaries = append(summaries, industry.IndustrySummary{
|
||||
Slug: t.Slug,
|
||||
Name: t.Name,
|
||||
Description: t.Description,
|
||||
Icon: t.Icon,
|
||||
RegulationCount: len(t.Regulations),
|
||||
TemplateCount: len(t.VVTTemplates),
|
||||
})
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, industry.IndustryListResponse{
|
||||
Industries: summaries,
|
||||
Total: len(summaries),
|
||||
})
|
||||
}
|
||||
|
||||
// GetIndustry returns the full industry template for a given slug.
|
||||
// GET /sdk/v1/industries/:slug
|
||||
func (h *IndustryHandlers) GetIndustry(c *gin.Context) {
|
||||
slug := c.Param("slug")
|
||||
|
||||
tmpl := industry.GetTemplateBySlug(slug)
|
||||
if tmpl == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "industry template not found", "slug": slug})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, tmpl)
|
||||
}
|
||||
|
||||
// GetVVTTemplates returns only the VVT templates for a given industry.
|
||||
// GET /sdk/v1/industries/:slug/vvt-templates
|
||||
func (h *IndustryHandlers) GetVVTTemplates(c *gin.Context) {
|
||||
slug := c.Param("slug")
|
||||
|
||||
tmpl := industry.GetTemplateBySlug(slug)
|
||||
if tmpl == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "industry template not found", "slug": slug})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"slug": tmpl.Slug,
|
||||
"industry": tmpl.Name,
|
||||
"vvt_templates": tmpl.VVTTemplates,
|
||||
"total": len(tmpl.VVTTemplates),
|
||||
})
|
||||
}
|
||||
|
||||
// GetTOMRecommendations returns only the TOM recommendations for a given industry.
|
||||
// GET /sdk/v1/industries/:slug/tom-recommendations
|
||||
func (h *IndustryHandlers) GetTOMRecommendations(c *gin.Context) {
|
||||
slug := c.Param("slug")
|
||||
|
||||
tmpl := industry.GetTemplateBySlug(slug)
|
||||
if tmpl == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "industry template not found", "slug": slug})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"slug": tmpl.Slug,
|
||||
"industry": tmpl.Name,
|
||||
"tom_recommendations": tmpl.TOMRecommendations,
|
||||
"total": len(tmpl.TOMRecommendations),
|
||||
})
|
||||
}
|
||||
|
||||
// GetRiskScenarios returns only the risk scenarios for a given industry.
|
||||
// GET /sdk/v1/industries/:slug/risk-scenarios
|
||||
func (h *IndustryHandlers) GetRiskScenarios(c *gin.Context) {
|
||||
slug := c.Param("slug")
|
||||
|
||||
tmpl := industry.GetTemplateBySlug(slug)
|
||||
if tmpl == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "industry template not found", "slug": slug})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"slug": tmpl.Slug,
|
||||
"industry": tmpl.Name,
|
||||
"risk_scenarios": tmpl.RiskScenarios,
|
||||
"total": len(tmpl.RiskScenarios),
|
||||
})
|
||||
}
|
||||
268
ai-compliance-sdk/internal/api/handlers/multitenant_handlers.go
Normal file
268
ai-compliance-sdk/internal/api/handlers/multitenant_handlers.go
Normal file
@@ -0,0 +1,268 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/breakpilot/ai-compliance-sdk/internal/multitenant"
|
||||
"github.com/breakpilot/ai-compliance-sdk/internal/rbac"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// MultiTenantHandlers handles multi-tenant administration endpoints.
|
||||
type MultiTenantHandlers struct {
|
||||
store *multitenant.Store
|
||||
rbacStore *rbac.Store
|
||||
}
|
||||
|
||||
// NewMultiTenantHandlers creates new multi-tenant handlers.
|
||||
func NewMultiTenantHandlers(store *multitenant.Store, rbacStore *rbac.Store) *MultiTenantHandlers {
|
||||
return &MultiTenantHandlers{
|
||||
store: store,
|
||||
rbacStore: rbacStore,
|
||||
}
|
||||
}
|
||||
|
||||
// GetOverview returns all tenants with compliance scores and module highlights.
|
||||
// GET /sdk/v1/multi-tenant/overview
|
||||
func (h *MultiTenantHandlers) GetOverview(c *gin.Context) {
|
||||
overview, err := h.store.GetOverview(c.Request.Context())
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, overview)
|
||||
}
|
||||
|
||||
// GetTenantDetail returns detailed compliance info for one tenant.
|
||||
// GET /sdk/v1/multi-tenant/tenants/:id
|
||||
func (h *MultiTenantHandlers) GetTenantDetail(c *gin.Context) {
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid tenant ID"})
|
||||
return
|
||||
}
|
||||
|
||||
detail, err := h.store.GetTenantDetail(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "tenant not found"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, detail)
|
||||
}
|
||||
|
||||
// CreateTenant creates a new tenant with default setup.
|
||||
// It creates the tenant via the RBAC store and then creates a default "main" namespace.
|
||||
// POST /sdk/v1/multi-tenant/tenants
|
||||
func (h *MultiTenantHandlers) CreateTenant(c *gin.Context) {
|
||||
var req multitenant.CreateTenantRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Build the tenant from the request
|
||||
tenant := &rbac.Tenant{
|
||||
Name: req.Name,
|
||||
Slug: req.Slug,
|
||||
MaxUsers: req.MaxUsers,
|
||||
LLMQuotaMonthly: req.LLMQuotaMonthly,
|
||||
}
|
||||
|
||||
// Create tenant via RBAC store (assigns ID, timestamps, defaults)
|
||||
if err := h.rbacStore.CreateTenant(c.Request.Context(), tenant); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Create default "main" namespace for the new tenant
|
||||
defaultNamespace := &rbac.Namespace{
|
||||
TenantID: tenant.ID,
|
||||
Name: "Main",
|
||||
Slug: "main",
|
||||
}
|
||||
if err := h.rbacStore.CreateNamespace(c.Request.Context(), defaultNamespace); err != nil {
|
||||
// Tenant was created successfully but namespace creation failed.
|
||||
// Log and continue -- the tenant is still usable.
|
||||
c.JSON(http.StatusCreated, gin.H{
|
||||
"tenant": tenant,
|
||||
"warning": "tenant created but default namespace creation failed: " + err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, gin.H{
|
||||
"tenant": tenant,
|
||||
"namespace": defaultNamespace,
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateTenant performs a partial update of tenant settings.
|
||||
// Only non-nil fields in the request body are applied.
|
||||
// PUT /sdk/v1/multi-tenant/tenants/:id
|
||||
func (h *MultiTenantHandlers) UpdateTenant(c *gin.Context) {
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid tenant ID"})
|
||||
return
|
||||
}
|
||||
|
||||
var req multitenant.UpdateTenantRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Fetch the existing tenant so we can apply partial updates
|
||||
tenant, err := h.rbacStore.GetTenant(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "tenant not found"})
|
||||
return
|
||||
}
|
||||
|
||||
// Apply only the fields that were provided
|
||||
if req.Name != nil {
|
||||
tenant.Name = *req.Name
|
||||
}
|
||||
if req.MaxUsers != nil {
|
||||
tenant.MaxUsers = *req.MaxUsers
|
||||
}
|
||||
if req.LLMQuotaMonthly != nil {
|
||||
tenant.LLMQuotaMonthly = *req.LLMQuotaMonthly
|
||||
}
|
||||
if req.Status != nil {
|
||||
tenant.Status = rbac.TenantStatus(*req.Status)
|
||||
}
|
||||
|
||||
if err := h.rbacStore.UpdateTenant(c.Request.Context(), tenant); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, tenant)
|
||||
}
|
||||
|
||||
// ListNamespaces returns all namespaces for a specific tenant.
|
||||
// GET /sdk/v1/multi-tenant/tenants/:id/namespaces
|
||||
func (h *MultiTenantHandlers) ListNamespaces(c *gin.Context) {
|
||||
tenantID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid tenant ID"})
|
||||
return
|
||||
}
|
||||
|
||||
namespaces, err := h.rbacStore.ListNamespaces(c.Request.Context(), tenantID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"namespaces": namespaces,
|
||||
"total": len(namespaces),
|
||||
})
|
||||
}
|
||||
|
||||
// CreateNamespace creates a new namespace within a tenant.
|
||||
// POST /sdk/v1/multi-tenant/tenants/:id/namespaces
|
||||
func (h *MultiTenantHandlers) CreateNamespace(c *gin.Context) {
|
||||
tenantID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid tenant ID"})
|
||||
return
|
||||
}
|
||||
|
||||
// Verify the tenant exists
|
||||
_, err = h.rbacStore.GetTenant(c.Request.Context(), tenantID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "tenant not found"})
|
||||
return
|
||||
}
|
||||
|
||||
var req multitenant.CreateNamespaceRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
namespace := &rbac.Namespace{
|
||||
TenantID: tenantID,
|
||||
Name: req.Name,
|
||||
Slug: req.Slug,
|
||||
}
|
||||
|
||||
// Apply optional fields if provided
|
||||
if req.IsolationLevel != "" {
|
||||
namespace.IsolationLevel = rbac.IsolationLevel(req.IsolationLevel)
|
||||
}
|
||||
if req.DataClassification != "" {
|
||||
namespace.DataClassification = rbac.DataClassification(req.DataClassification)
|
||||
}
|
||||
|
||||
if err := h.rbacStore.CreateNamespace(c.Request.Context(), namespace); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, namespace)
|
||||
}
|
||||
|
||||
// SwitchTenant returns the tenant info needed for the frontend to switch context.
|
||||
// The caller provides a tenant_id and receives back the tenant details needed
|
||||
// to update the frontend's active tenant state.
|
||||
// POST /sdk/v1/multi-tenant/switch
|
||||
func (h *MultiTenantHandlers) SwitchTenant(c *gin.Context) {
|
||||
var req multitenant.SwitchTenantRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
tenantID, err := uuid.Parse(req.TenantID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid tenant ID"})
|
||||
return
|
||||
}
|
||||
|
||||
tenant, err := h.rbacStore.GetTenant(c.Request.Context(), tenantID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "tenant not found"})
|
||||
return
|
||||
}
|
||||
|
||||
// Verify the tenant is active
|
||||
if tenant.Status != rbac.TenantStatusActive {
|
||||
c.JSON(http.StatusForbidden, gin.H{
|
||||
"error": "tenant not active",
|
||||
"status": string(tenant.Status),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Get namespaces for the tenant so the frontend can populate namespace selectors
|
||||
namespaces, err := h.rbacStore.ListNamespaces(c.Request.Context(), tenantID)
|
||||
if err != nil {
|
||||
// Non-fatal: return tenant info without namespaces
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"tenant": multitenant.SwitchTenantResponse{
|
||||
TenantID: tenant.ID,
|
||||
TenantName: tenant.Name,
|
||||
TenantSlug: tenant.Slug,
|
||||
Status: string(tenant.Status),
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"tenant": multitenant.SwitchTenantResponse{
|
||||
TenantID: tenant.ID,
|
||||
TenantName: tenant.Name,
|
||||
TenantSlug: tenant.Slug,
|
||||
Status: string(tenant.Status),
|
||||
},
|
||||
"namespaces": namespaces,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/breakpilot/ai-compliance-sdk/internal/rbac"
|
||||
"github.com/breakpilot/ai-compliance-sdk/internal/reporting"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type ReportingHandlers struct {
|
||||
store *reporting.Store
|
||||
}
|
||||
|
||||
func NewReportingHandlers(store *reporting.Store) *ReportingHandlers {
|
||||
return &ReportingHandlers{store: store}
|
||||
}
|
||||
|
||||
// GetExecutiveReport generates a comprehensive compliance report
|
||||
// GET /sdk/v1/reporting/executive
|
||||
func (h *ReportingHandlers) GetExecutiveReport(c *gin.Context) {
|
||||
tenantID := rbac.GetTenantID(c)
|
||||
|
||||
report, err := h.store.GenerateReport(c.Request.Context(), tenantID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, report)
|
||||
}
|
||||
|
||||
// GetComplianceScore returns just the overall compliance score
|
||||
// GET /sdk/v1/reporting/score
|
||||
func (h *ReportingHandlers) GetComplianceScore(c *gin.Context) {
|
||||
tenantID := rbac.GetTenantID(c)
|
||||
|
||||
report, err := h.store.GenerateReport(c.Request.Context(), tenantID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"compliance_score": report.ComplianceScore,
|
||||
"risk_level": report.RiskOverview.OverallLevel,
|
||||
"generated_at": report.GeneratedAt,
|
||||
})
|
||||
}
|
||||
|
||||
// GetUpcomingDeadlines returns deadlines across all modules
|
||||
// GET /sdk/v1/reporting/deadlines
|
||||
func (h *ReportingHandlers) GetUpcomingDeadlines(c *gin.Context) {
|
||||
tenantID := rbac.GetTenantID(c)
|
||||
|
||||
report, err := h.store.GenerateReport(c.Request.Context(), tenantID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"deadlines": report.UpcomingDeadlines,
|
||||
"total": len(report.UpcomingDeadlines),
|
||||
})
|
||||
}
|
||||
|
||||
// GetRiskOverview returns the aggregated risk assessment
|
||||
// GET /sdk/v1/reporting/risks
|
||||
func (h *ReportingHandlers) GetRiskOverview(c *gin.Context) {
|
||||
tenantID := rbac.GetTenantID(c)
|
||||
|
||||
report, err := h.store.GenerateReport(c.Request.Context(), tenantID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, report.RiskOverview)
|
||||
}
|
||||
631
ai-compliance-sdk/internal/api/handlers/sso_handlers.go
Normal file
631
ai-compliance-sdk/internal/api/handlers/sso_handlers.go
Normal file
@@ -0,0 +1,631 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/breakpilot/ai-compliance-sdk/internal/rbac"
|
||||
"github.com/breakpilot/ai-compliance-sdk/internal/sso"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// SSOHandlers handles SSO-related HTTP requests.
|
||||
type SSOHandlers struct {
|
||||
store *sso.Store
|
||||
jwtSecret string
|
||||
}
|
||||
|
||||
// NewSSOHandlers creates new SSO handlers.
|
||||
func NewSSOHandlers(store *sso.Store, jwtSecret string) *SSOHandlers {
|
||||
return &SSOHandlers{store: store, jwtSecret: jwtSecret}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// SSO Configuration CRUD
|
||||
// ============================================================================
|
||||
|
||||
// CreateConfig creates a new SSO configuration for the tenant.
|
||||
// POST /sdk/v1/sso/configs
|
||||
func (h *SSOHandlers) CreateConfig(c *gin.Context) {
|
||||
var req sso.CreateSSOConfigRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
tenantID := rbac.GetTenantID(c)
|
||||
|
||||
cfg, err := h.store.CreateConfig(c.Request.Context(), tenantID, &req)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, gin.H{"config": cfg})
|
||||
}
|
||||
|
||||
// ListConfigs lists all SSO configurations for the tenant.
|
||||
// GET /sdk/v1/sso/configs
|
||||
func (h *SSOHandlers) ListConfigs(c *gin.Context) {
|
||||
tenantID := rbac.GetTenantID(c)
|
||||
|
||||
configs, err := h.store.ListConfigs(c.Request.Context(), tenantID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"configs": configs,
|
||||
"total": len(configs),
|
||||
})
|
||||
}
|
||||
|
||||
// GetConfig retrieves an SSO configuration by ID.
|
||||
// GET /sdk/v1/sso/configs/:id
|
||||
func (h *SSOHandlers) GetConfig(c *gin.Context) {
|
||||
tenantID := rbac.GetTenantID(c)
|
||||
|
||||
configID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid config ID"})
|
||||
return
|
||||
}
|
||||
|
||||
cfg, err := h.store.GetConfig(c.Request.Context(), tenantID, configID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if cfg == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "sso configuration not found"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"config": cfg})
|
||||
}
|
||||
|
||||
// UpdateConfig updates an SSO configuration.
|
||||
// PUT /sdk/v1/sso/configs/:id
|
||||
func (h *SSOHandlers) UpdateConfig(c *gin.Context) {
|
||||
tenantID := rbac.GetTenantID(c)
|
||||
|
||||
configID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid config ID"})
|
||||
return
|
||||
}
|
||||
|
||||
var req sso.UpdateSSOConfigRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
cfg, err := h.store.UpdateConfig(c.Request.Context(), tenantID, configID, &req)
|
||||
if err != nil {
|
||||
if err.Error() == "sso configuration not found" {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"config": cfg})
|
||||
}
|
||||
|
||||
// DeleteConfig deletes an SSO configuration.
|
||||
// DELETE /sdk/v1/sso/configs/:id
|
||||
func (h *SSOHandlers) DeleteConfig(c *gin.Context) {
|
||||
tenantID := rbac.GetTenantID(c)
|
||||
|
||||
configID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid config ID"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.store.DeleteConfig(c.Request.Context(), tenantID, configID); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "sso configuration deleted"})
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// SSO Users
|
||||
// ============================================================================
|
||||
|
||||
// ListUsers lists all SSO-provisioned users for the tenant.
|
||||
// GET /sdk/v1/sso/users
|
||||
func (h *SSOHandlers) ListUsers(c *gin.Context) {
|
||||
tenantID := rbac.GetTenantID(c)
|
||||
|
||||
users, err := h.store.ListUsers(c.Request.Context(), tenantID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"users": users,
|
||||
"total": len(users),
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// OIDC Flow
|
||||
// ============================================================================
|
||||
|
||||
// InitiateOIDCLogin initiates the OIDC authorization code flow.
|
||||
// It looks up the enabled SSO config for the tenant, builds the authorization
|
||||
// URL, sets a state cookie, and redirects the user to the IdP.
|
||||
// GET /sdk/v1/sso/oidc/login
|
||||
func (h *SSOHandlers) InitiateOIDCLogin(c *gin.Context) {
|
||||
// Resolve tenant ID from query param
|
||||
tenantIDStr := c.Query("tenant_id")
|
||||
if tenantIDStr == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "tenant_id query parameter is required"})
|
||||
return
|
||||
}
|
||||
|
||||
tenantID, err := uuid.Parse(tenantIDStr)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid tenant_id"})
|
||||
return
|
||||
}
|
||||
|
||||
// Look up the enabled SSO config
|
||||
cfg, err := h.store.GetEnabledConfig(c.Request.Context(), tenantID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if cfg == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "no enabled SSO configuration found for this tenant"})
|
||||
return
|
||||
}
|
||||
if cfg.ProviderType != sso.ProviderTypeOIDC {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "SSO configuration is not OIDC"})
|
||||
return
|
||||
}
|
||||
|
||||
// Discover the authorization endpoint
|
||||
discoveryURL := strings.TrimSuffix(cfg.OIDCIssuerURL, "/") + "/.well-known/openid-configuration"
|
||||
authEndpoint, _, _, err := discoverOIDCEndpoints(discoveryURL)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("OIDC discovery failed: %v", err)})
|
||||
return
|
||||
}
|
||||
|
||||
// Generate state parameter (random bytes + tenant_id for correlation)
|
||||
stateBytes := make([]byte, 32)
|
||||
if _, err := rand.Read(stateBytes); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate state"})
|
||||
return
|
||||
}
|
||||
state := base64.URLEncoding.EncodeToString(stateBytes) + "." + tenantID.String()
|
||||
|
||||
// Generate nonce
|
||||
nonceBytes := make([]byte, 16)
|
||||
if _, err := rand.Read(nonceBytes); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate nonce"})
|
||||
return
|
||||
}
|
||||
nonce := base64.URLEncoding.EncodeToString(nonceBytes)
|
||||
|
||||
// Build authorization URL
|
||||
scopes := cfg.OIDCScopes
|
||||
if len(scopes) == 0 {
|
||||
scopes = []string{"openid", "profile", "email"}
|
||||
}
|
||||
|
||||
params := url.Values{
|
||||
"client_id": {cfg.OIDCClientID},
|
||||
"redirect_uri": {cfg.OIDCRedirectURI},
|
||||
"response_type": {"code"},
|
||||
"scope": {strings.Join(scopes, " ")},
|
||||
"state": {state},
|
||||
"nonce": {nonce},
|
||||
}
|
||||
|
||||
authURL := authEndpoint + "?" + params.Encode()
|
||||
|
||||
// Set state cookie for CSRF protection (HttpOnly, 10 min expiry)
|
||||
c.SetCookie("sso_state", state, 600, "/", "", true, true)
|
||||
c.SetCookie("sso_nonce", nonce, 600, "/", "", true, true)
|
||||
|
||||
c.Redirect(http.StatusFound, authURL)
|
||||
}
|
||||
|
||||
// HandleOIDCCallback handles the OIDC authorization code callback from the IdP.
|
||||
// It validates the state, exchanges the code for tokens, extracts user info,
|
||||
// performs JIT user provisioning, and issues a JWT.
|
||||
// GET /sdk/v1/sso/oidc/callback
|
||||
func (h *SSOHandlers) HandleOIDCCallback(c *gin.Context) {
|
||||
// Check for errors from the IdP
|
||||
if errParam := c.Query("error"); errParam != "" {
|
||||
errDesc := c.Query("error_description")
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": errParam,
|
||||
"description": errDesc,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
code := c.Query("code")
|
||||
stateParam := c.Query("state")
|
||||
if code == "" || stateParam == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "missing code or state parameter"})
|
||||
return
|
||||
}
|
||||
|
||||
// Validate state cookie
|
||||
stateCookie, err := c.Cookie("sso_state")
|
||||
if err != nil || stateCookie != stateParam {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid state parameter (CSRF check failed)"})
|
||||
return
|
||||
}
|
||||
|
||||
// Extract tenant ID from state
|
||||
parts := strings.SplitN(stateParam, ".", 2)
|
||||
if len(parts) != 2 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "malformed state parameter"})
|
||||
return
|
||||
}
|
||||
tenantID, err := uuid.Parse(parts[1])
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid tenant_id in state"})
|
||||
return
|
||||
}
|
||||
|
||||
// Look up the enabled SSO config
|
||||
cfg, err := h.store.GetEnabledConfig(c.Request.Context(), tenantID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if cfg == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "no enabled SSO configuration found"})
|
||||
return
|
||||
}
|
||||
|
||||
// Discover OIDC endpoints
|
||||
discoveryURL := strings.TrimSuffix(cfg.OIDCIssuerURL, "/") + "/.well-known/openid-configuration"
|
||||
_, tokenEndpoint, userInfoEndpoint, err := discoverOIDCEndpoints(discoveryURL)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("OIDC discovery failed: %v", err)})
|
||||
return
|
||||
}
|
||||
|
||||
// Exchange authorization code for tokens
|
||||
tokenResp, err := exchangeCodeForTokens(tokenEndpoint, code, cfg.OIDCClientID, cfg.OIDCClientSecret, cfg.OIDCRedirectURI)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("token exchange failed: %v", err)})
|
||||
return
|
||||
}
|
||||
|
||||
// Extract user claims from ID token or UserInfo endpoint
|
||||
claims, err := extractUserClaims(tokenResp, userInfoEndpoint)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to extract user claims: %v", err)})
|
||||
return
|
||||
}
|
||||
|
||||
sub := getStringClaim(claims, "sub")
|
||||
email := getStringClaim(claims, "email")
|
||||
name := getStringClaim(claims, "name")
|
||||
groups := getStringSliceClaim(claims, "groups")
|
||||
|
||||
if sub == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "ID token missing 'sub' claim"})
|
||||
return
|
||||
}
|
||||
if email == "" {
|
||||
email = sub
|
||||
}
|
||||
if name == "" {
|
||||
name = email
|
||||
}
|
||||
|
||||
// JIT provision the user
|
||||
user, err := h.store.UpsertUser(c.Request.Context(), tenantID, cfg.ID, sub, email, name, groups)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("user provisioning failed: %v", err)})
|
||||
return
|
||||
}
|
||||
|
||||
// Determine roles from role mapping
|
||||
roles := resolveRoles(cfg, groups)
|
||||
|
||||
// Generate JWT
|
||||
ssoClaims := sso.SSOClaims{
|
||||
UserID: user.ID,
|
||||
TenantID: tenantID,
|
||||
Email: user.Email,
|
||||
DisplayName: user.DisplayName,
|
||||
Roles: roles,
|
||||
SSOConfigID: cfg.ID,
|
||||
}
|
||||
|
||||
jwtToken, err := h.generateJWT(ssoClaims)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("JWT generation failed: %v", err)})
|
||||
return
|
||||
}
|
||||
|
||||
// Clear state cookies
|
||||
c.SetCookie("sso_state", "", -1, "/", "", true, true)
|
||||
c.SetCookie("sso_nonce", "", -1, "/", "", true, true)
|
||||
|
||||
// Return JWT as JSON (the frontend can also handle redirect)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"token": jwtToken,
|
||||
"user": user,
|
||||
"roles": roles,
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// JWT Generation
|
||||
// ============================================================================
|
||||
|
||||
// generateJWT creates a signed JWT token containing the SSO claims.
|
||||
func (h *SSOHandlers) generateJWT(claims sso.SSOClaims) (string, error) {
|
||||
now := time.Now().UTC()
|
||||
expiry := now.Add(24 * time.Hour)
|
||||
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
|
||||
"user_id": claims.UserID.String(),
|
||||
"tenant_id": claims.TenantID.String(),
|
||||
"email": claims.Email,
|
||||
"display_name": claims.DisplayName,
|
||||
"roles": claims.Roles,
|
||||
"sso_config_id": claims.SSOConfigID.String(),
|
||||
"iss": "ai-compliance-sdk",
|
||||
"iat": now.Unix(),
|
||||
"exp": expiry.Unix(),
|
||||
})
|
||||
|
||||
tokenString, err := token.SignedString([]byte(h.jwtSecret))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to sign JWT: %w", err)
|
||||
}
|
||||
|
||||
return tokenString, nil
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// OIDC Discovery & Token Exchange (manual HTTP, no external OIDC library)
|
||||
// ============================================================================
|
||||
|
||||
// oidcDiscoveryResponse holds the relevant fields from the OIDC discovery document.
|
||||
type oidcDiscoveryResponse struct {
|
||||
AuthorizationEndpoint string `json:"authorization_endpoint"`
|
||||
TokenEndpoint string `json:"token_endpoint"`
|
||||
UserinfoEndpoint string `json:"userinfo_endpoint"`
|
||||
JwksURI string `json:"jwks_uri"`
|
||||
Issuer string `json:"issuer"`
|
||||
}
|
||||
|
||||
// discoverOIDCEndpoints fetches the OIDC discovery document and returns
|
||||
// the authorization, token, and userinfo endpoints.
|
||||
func discoverOIDCEndpoints(discoveryURL string) (authEndpoint, tokenEndpoint, userInfoEndpoint string, err error) {
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
|
||||
resp, err := client.Get(discoveryURL)
|
||||
if err != nil {
|
||||
return "", "", "", fmt.Errorf("failed to fetch discovery document: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return "", "", "", fmt.Errorf("discovery endpoint returned %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var discovery oidcDiscoveryResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&discovery); err != nil {
|
||||
return "", "", "", fmt.Errorf("failed to decode discovery document: %w", err)
|
||||
}
|
||||
|
||||
if discovery.AuthorizationEndpoint == "" {
|
||||
return "", "", "", fmt.Errorf("discovery document missing authorization_endpoint")
|
||||
}
|
||||
if discovery.TokenEndpoint == "" {
|
||||
return "", "", "", fmt.Errorf("discovery document missing token_endpoint")
|
||||
}
|
||||
|
||||
return discovery.AuthorizationEndpoint, discovery.TokenEndpoint, discovery.UserinfoEndpoint, nil
|
||||
}
|
||||
|
||||
// oidcTokenResponse holds the response from the OIDC token endpoint.
|
||||
type oidcTokenResponse struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
IDToken string `json:"id_token"`
|
||||
TokenType string `json:"token_type"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
RefreshToken string `json:"refresh_token,omitempty"`
|
||||
}
|
||||
|
||||
// exchangeCodeForTokens exchanges an authorization code for tokens at the token endpoint.
|
||||
func exchangeCodeForTokens(tokenEndpoint, code, clientID, clientSecret, redirectURI string) (*oidcTokenResponse, error) {
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
|
||||
data := url.Values{
|
||||
"grant_type": {"authorization_code"},
|
||||
"code": {code},
|
||||
"client_id": {clientID},
|
||||
"redirect_uri": {redirectURI},
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("POST", tokenEndpoint, strings.NewReader(data.Encode()))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create token request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
|
||||
// Use client_secret_basic if provided
|
||||
if clientSecret != "" {
|
||||
req.SetBasicAuth(clientID, clientSecret)
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("token request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return nil, fmt.Errorf("token endpoint returned %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var tokenResp oidcTokenResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode token response: %w", err)
|
||||
}
|
||||
|
||||
return &tokenResp, nil
|
||||
}
|
||||
|
||||
// extractUserClaims extracts user claims from the ID token payload.
|
||||
// If the ID token is unavailable or incomplete, it falls back to the UserInfo endpoint.
|
||||
func extractUserClaims(tokenResp *oidcTokenResponse, userInfoEndpoint string) (map[string]interface{}, error) {
|
||||
claims := make(map[string]interface{})
|
||||
|
||||
// Try to decode ID token payload (without signature verification for claims extraction;
|
||||
// in production, you should verify the signature using the JWKS endpoint)
|
||||
if tokenResp.IDToken != "" {
|
||||
parts := strings.Split(tokenResp.IDToken, ".")
|
||||
if len(parts) == 3 {
|
||||
payload, err := base64.RawURLEncoding.DecodeString(parts[1])
|
||||
if err == nil {
|
||||
if err := json.Unmarshal(payload, &claims); err == nil && claims["sub"] != nil {
|
||||
return claims, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to UserInfo endpoint
|
||||
if userInfoEndpoint != "" && tokenResp.AccessToken != "" {
|
||||
userClaims, err := fetchUserInfo(userInfoEndpoint, tokenResp.AccessToken)
|
||||
if err == nil && userClaims["sub"] != nil {
|
||||
return userClaims, nil
|
||||
}
|
||||
}
|
||||
|
||||
if claims["sub"] != nil {
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("could not extract user claims from ID token or UserInfo endpoint")
|
||||
}
|
||||
|
||||
// fetchUserInfo calls the OIDC UserInfo endpoint with the access token.
|
||||
func fetchUserInfo(userInfoEndpoint, accessToken string) (map[string]interface{}, error) {
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
|
||||
req, err := http.NewRequest("GET", userInfoEndpoint, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+accessToken)
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("userinfo endpoint returned %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var claims map[string]interface{}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&claims); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Claim Extraction Helpers
|
||||
// ============================================================================
|
||||
|
||||
// getStringClaim extracts a string claim from a claims map.
|
||||
func getStringClaim(claims map[string]interface{}, key string) string {
|
||||
if v, ok := claims[key]; ok {
|
||||
if s, ok := v.(string); ok {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// getStringSliceClaim extracts a string slice claim from a claims map.
|
||||
func getStringSliceClaim(claims map[string]interface{}, key string) []string {
|
||||
v, ok := claims[key]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
switch val := v.(type) {
|
||||
case []interface{}:
|
||||
result := make([]string, 0, len(val))
|
||||
for _, item := range val {
|
||||
if s, ok := item.(string); ok {
|
||||
result = append(result, s)
|
||||
}
|
||||
}
|
||||
return result
|
||||
case []string:
|
||||
return val
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// resolveRoles maps SSO groups to internal roles using the config's role mapping.
|
||||
// If no groups match, the default role is returned.
|
||||
func resolveRoles(cfg *sso.SSOConfig, groups []string) []string {
|
||||
if cfg.RoleMapping == nil || len(cfg.RoleMapping) == 0 {
|
||||
if cfg.DefaultRoleID != nil {
|
||||
return []string{cfg.DefaultRoleID.String()}
|
||||
}
|
||||
return []string{"compliance_user"}
|
||||
}
|
||||
|
||||
roleSet := make(map[string]bool)
|
||||
for _, group := range groups {
|
||||
if role, ok := cfg.RoleMapping[group]; ok {
|
||||
roleSet[role] = true
|
||||
}
|
||||
}
|
||||
|
||||
if len(roleSet) == 0 {
|
||||
if cfg.DefaultRoleID != nil {
|
||||
return []string{cfg.DefaultRoleID.String()}
|
||||
}
|
||||
return []string{"compliance_user"}
|
||||
}
|
||||
|
||||
roles := make([]string, 0, len(roleSet))
|
||||
for role := range roleSet {
|
||||
roles = append(roles, role)
|
||||
}
|
||||
return roles
|
||||
}
|
||||
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