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>
452 lines
13 KiB
Go
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),
|
|
})
|
|
}
|