Files
breakpilot-compliance/ai-compliance-sdk/internal/api/handlers/dsb_handlers.go
Benjamin Boenisch 504dd3591b 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>
2026-02-13 21:11:27 +01:00

452 lines
13 KiB
Go

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