Files
breakpilot-compliance/ai-compliance-sdk/internal/dsb/store.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

511 lines
15 KiB
Go

package dsb
import (
"context"
"fmt"
"time"
"github.com/breakpilot/ai-compliance-sdk/internal/reporting"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
)
// Store provides database operations for the DSB portal.
type Store struct {
pool *pgxpool.Pool
reportingStore *reporting.Store
}
// NewStore creates a new DSB store.
func NewStore(pool *pgxpool.Pool, reportingStore *reporting.Store) *Store {
return &Store{
pool: pool,
reportingStore: reportingStore,
}
}
// Pool returns the underlying connection pool for direct queries when needed.
func (s *Store) Pool() *pgxpool.Pool {
return s.pool
}
// ============================================================================
// Dashboard
// ============================================================================
// GetDashboard generates the aggregated DSB dashboard for a given DSB user.
func (s *Store) GetDashboard(ctx context.Context, dsbUserID uuid.UUID) (*DSBDashboard, error) {
assignments, err := s.ListAssignments(ctx, dsbUserID)
if err != nil {
return nil, fmt.Errorf("list assignments: %w", err)
}
now := time.Now().UTC()
currentMonth := now.Format("2006-01")
dashboard := &DSBDashboard{
Assignments: make([]AssignmentOverview, 0, len(assignments)),
GeneratedAt: now,
}
for _, a := range assignments {
overview := AssignmentOverview{
Assignment: a,
HoursBudget: a.MonthlyHoursBudget,
}
// Enrich with compliance score (error-tolerant)
if s.reportingStore != nil {
report, err := s.reportingStore.GenerateReport(ctx, a.TenantID)
if err == nil && report != nil {
overview.ComplianceScore = report.ComplianceScore
}
}
// Hours this month
summary, err := s.GetHoursSummary(ctx, a.ID, currentMonth)
if err == nil && summary != nil {
overview.HoursThisMonth = summary.TotalHours
}
// Open and urgent tasks
openTasks, err := s.ListTasks(ctx, a.ID, "open")
if err == nil {
overview.OpenTaskCount = len(openTasks)
for _, t := range openTasks {
if t.Priority == "urgent" {
overview.UrgentTaskCount++
}
if t.DueDate != nil && (overview.NextDeadline == nil || t.DueDate.Before(*overview.NextDeadline)) {
overview.NextDeadline = t.DueDate
}
}
}
// Also count in_progress tasks
inProgressTasks, err := s.ListTasks(ctx, a.ID, "in_progress")
if err == nil {
overview.OpenTaskCount += len(inProgressTasks)
for _, t := range inProgressTasks {
if t.Priority == "urgent" {
overview.UrgentTaskCount++
}
if t.DueDate != nil && (overview.NextDeadline == nil || t.DueDate.Before(*overview.NextDeadline)) {
overview.NextDeadline = t.DueDate
}
}
}
dashboard.Assignments = append(dashboard.Assignments, overview)
dashboard.TotalAssignments++
if a.Status == "active" {
dashboard.ActiveAssignments++
}
dashboard.TotalHoursThisMonth += overview.HoursThisMonth
dashboard.OpenTasks += overview.OpenTaskCount
dashboard.UrgentTasks += overview.UrgentTaskCount
}
return dashboard, nil
}
// ============================================================================
// Assignments
// ============================================================================
// CreateAssignment inserts a new DSB assignment.
func (s *Store) CreateAssignment(ctx context.Context, a *Assignment) error {
a.ID = uuid.New()
now := time.Now().UTC()
a.CreatedAt = now
a.UpdatedAt = now
if a.Status == "" {
a.Status = "active"
}
_, err := s.pool.Exec(ctx, `
INSERT INTO dsb_assignments (id, dsb_user_id, tenant_id, status, contract_start, contract_end, monthly_hours_budget, notes, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
`, a.ID, a.DSBUserID, a.TenantID, a.Status, a.ContractStart, a.ContractEnd, a.MonthlyHoursBudget, a.Notes, a.CreatedAt, a.UpdatedAt)
if err != nil {
return fmt.Errorf("insert assignment: %w", err)
}
return nil
}
// ListAssignments returns all assignments for a given DSB user, joined with tenant info.
func (s *Store) ListAssignments(ctx context.Context, dsbUserID uuid.UUID) ([]Assignment, error) {
rows, err := s.pool.Query(ctx, `
SELECT a.id, a.dsb_user_id, a.tenant_id, ct.name, ct.slug,
a.status, a.contract_start, a.contract_end,
a.monthly_hours_budget, a.notes, a.created_at, a.updated_at
FROM dsb_assignments a
JOIN compliance_tenants ct ON ct.id = a.tenant_id
WHERE a.dsb_user_id = $1
ORDER BY a.created_at DESC
`, dsbUserID)
if err != nil {
return nil, fmt.Errorf("query assignments: %w", err)
}
defer rows.Close()
var assignments []Assignment
for rows.Next() {
var a Assignment
if err := rows.Scan(
&a.ID, &a.DSBUserID, &a.TenantID, &a.TenantName, &a.TenantSlug,
&a.Status, &a.ContractStart, &a.ContractEnd,
&a.MonthlyHoursBudget, &a.Notes, &a.CreatedAt, &a.UpdatedAt,
); err != nil {
return nil, fmt.Errorf("scan assignment: %w", err)
}
assignments = append(assignments, a)
}
if assignments == nil {
assignments = []Assignment{}
}
return assignments, nil
}
// GetAssignment retrieves a single assignment by ID.
func (s *Store) GetAssignment(ctx context.Context, id uuid.UUID) (*Assignment, error) {
var a Assignment
err := s.pool.QueryRow(ctx, `
SELECT a.id, a.dsb_user_id, a.tenant_id, ct.name, ct.slug,
a.status, a.contract_start, a.contract_end,
a.monthly_hours_budget, a.notes, a.created_at, a.updated_at
FROM dsb_assignments a
JOIN compliance_tenants ct ON ct.id = a.tenant_id
WHERE a.id = $1
`, id).Scan(
&a.ID, &a.DSBUserID, &a.TenantID, &a.TenantName, &a.TenantSlug,
&a.Status, &a.ContractStart, &a.ContractEnd,
&a.MonthlyHoursBudget, &a.Notes, &a.CreatedAt, &a.UpdatedAt,
)
if err != nil {
return nil, fmt.Errorf("get assignment: %w", err)
}
return &a, nil
}
// UpdateAssignment updates an existing assignment.
func (s *Store) UpdateAssignment(ctx context.Context, a *Assignment) error {
_, err := s.pool.Exec(ctx, `
UPDATE dsb_assignments
SET status = $2, contract_end = $3, monthly_hours_budget = $4, notes = $5, updated_at = NOW()
WHERE id = $1
`, a.ID, a.Status, a.ContractEnd, a.MonthlyHoursBudget, a.Notes)
if err != nil {
return fmt.Errorf("update assignment: %w", err)
}
return nil
}
// ============================================================================
// Hours
// ============================================================================
// CreateHourEntry inserts a new time tracking entry.
func (s *Store) CreateHourEntry(ctx context.Context, h *HourEntry) error {
h.ID = uuid.New()
h.CreatedAt = time.Now().UTC()
_, err := s.pool.Exec(ctx, `
INSERT INTO dsb_hours (id, assignment_id, date, hours, category, description, billable, created_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
`, h.ID, h.AssignmentID, h.Date, h.Hours, h.Category, h.Description, h.Billable, h.CreatedAt)
if err != nil {
return fmt.Errorf("insert hour entry: %w", err)
}
return nil
}
// ListHours returns time entries for an assignment, optionally filtered by month (YYYY-MM).
func (s *Store) ListHours(ctx context.Context, assignmentID uuid.UUID, month string) ([]HourEntry, error) {
var query string
var args []interface{}
if month != "" {
query = `
SELECT id, assignment_id, date, hours, category, description, billable, created_at
FROM dsb_hours
WHERE assignment_id = $1 AND to_char(date, 'YYYY-MM') = $2
ORDER BY date DESC, created_at DESC
`
args = []interface{}{assignmentID, month}
} else {
query = `
SELECT id, assignment_id, date, hours, category, description, billable, created_at
FROM dsb_hours
WHERE assignment_id = $1
ORDER BY date DESC, created_at DESC
`
args = []interface{}{assignmentID}
}
rows, err := s.pool.Query(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("query hours: %w", err)
}
defer rows.Close()
var entries []HourEntry
for rows.Next() {
var h HourEntry
if err := rows.Scan(
&h.ID, &h.AssignmentID, &h.Date, &h.Hours, &h.Category,
&h.Description, &h.Billable, &h.CreatedAt,
); err != nil {
return nil, fmt.Errorf("scan hour entry: %w", err)
}
entries = append(entries, h)
}
if entries == nil {
entries = []HourEntry{}
}
return entries, nil
}
// GetHoursSummary returns aggregated hour statistics for an assignment, optionally filtered by month.
func (s *Store) GetHoursSummary(ctx context.Context, assignmentID uuid.UUID, month string) (*HoursSummary, error) {
summary := &HoursSummary{
ByCategory: make(map[string]float64),
Period: "all",
}
if month != "" {
summary.Period = month
}
// Total and billable hours
var totalQuery string
var totalArgs []interface{}
if month != "" {
totalQuery = `
SELECT COALESCE(SUM(hours), 0), COALESCE(SUM(CASE WHEN billable THEN hours ELSE 0 END), 0)
FROM dsb_hours
WHERE assignment_id = $1 AND to_char(date, 'YYYY-MM') = $2
`
totalArgs = []interface{}{assignmentID, month}
} else {
totalQuery = `
SELECT COALESCE(SUM(hours), 0), COALESCE(SUM(CASE WHEN billable THEN hours ELSE 0 END), 0)
FROM dsb_hours
WHERE assignment_id = $1
`
totalArgs = []interface{}{assignmentID}
}
err := s.pool.QueryRow(ctx, totalQuery, totalArgs...).Scan(&summary.TotalHours, &summary.BillableHours)
if err != nil {
return nil, fmt.Errorf("query hours summary totals: %w", err)
}
// Hours by category
var catQuery string
var catArgs []interface{}
if month != "" {
catQuery = `
SELECT category, COALESCE(SUM(hours), 0)
FROM dsb_hours
WHERE assignment_id = $1 AND to_char(date, 'YYYY-MM') = $2
GROUP BY category
`
catArgs = []interface{}{assignmentID, month}
} else {
catQuery = `
SELECT category, COALESCE(SUM(hours), 0)
FROM dsb_hours
WHERE assignment_id = $1
GROUP BY category
`
catArgs = []interface{}{assignmentID}
}
rows, err := s.pool.Query(ctx, catQuery, catArgs...)
if err != nil {
return nil, fmt.Errorf("query hours by category: %w", err)
}
defer rows.Close()
for rows.Next() {
var cat string
var hours float64
if err := rows.Scan(&cat, &hours); err != nil {
return nil, fmt.Errorf("scan category hours: %w", err)
}
summary.ByCategory[cat] = hours
}
return summary, nil
}
// ============================================================================
// Tasks
// ============================================================================
// CreateTask inserts a new DSB task.
func (s *Store) CreateTask(ctx context.Context, t *Task) error {
t.ID = uuid.New()
now := time.Now().UTC()
t.CreatedAt = now
t.UpdatedAt = now
if t.Status == "" {
t.Status = "open"
}
if t.Priority == "" {
t.Priority = "medium"
}
_, err := s.pool.Exec(ctx, `
INSERT INTO dsb_tasks (id, assignment_id, title, description, category, priority, status, due_date, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
`, t.ID, t.AssignmentID, t.Title, t.Description, t.Category, t.Priority, t.Status, t.DueDate, t.CreatedAt, t.UpdatedAt)
if err != nil {
return fmt.Errorf("insert task: %w", err)
}
return nil
}
// ListTasks returns tasks for an assignment, optionally filtered by status.
func (s *Store) ListTasks(ctx context.Context, assignmentID uuid.UUID, status string) ([]Task, error) {
var query string
var args []interface{}
if status != "" {
query = `
SELECT id, assignment_id, title, description, category, priority, status, due_date, completed_at, created_at, updated_at
FROM dsb_tasks
WHERE assignment_id = $1 AND status = $2
ORDER BY CASE priority
WHEN 'urgent' THEN 1
WHEN 'high' THEN 2
WHEN 'medium' THEN 3
WHEN 'low' THEN 4
ELSE 5
END, due_date ASC NULLS LAST, created_at DESC
`
args = []interface{}{assignmentID, status}
} else {
query = `
SELECT id, assignment_id, title, description, category, priority, status, due_date, completed_at, created_at, updated_at
FROM dsb_tasks
WHERE assignment_id = $1
ORDER BY CASE priority
WHEN 'urgent' THEN 1
WHEN 'high' THEN 2
WHEN 'medium' THEN 3
WHEN 'low' THEN 4
ELSE 5
END, due_date ASC NULLS LAST, created_at DESC
`
args = []interface{}{assignmentID}
}
rows, err := s.pool.Query(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("query tasks: %w", err)
}
defer rows.Close()
var tasks []Task
for rows.Next() {
var t Task
if err := rows.Scan(
&t.ID, &t.AssignmentID, &t.Title, &t.Description, &t.Category,
&t.Priority, &t.Status, &t.DueDate, &t.CompletedAt,
&t.CreatedAt, &t.UpdatedAt,
); err != nil {
return nil, fmt.Errorf("scan task: %w", err)
}
tasks = append(tasks, t)
}
if tasks == nil {
tasks = []Task{}
}
return tasks, nil
}
// UpdateTask updates an existing task.
func (s *Store) UpdateTask(ctx context.Context, t *Task) error {
_, err := s.pool.Exec(ctx, `
UPDATE dsb_tasks
SET title = $2, description = $3, category = $4, priority = $5, status = $6, due_date = $7, updated_at = NOW()
WHERE id = $1
`, t.ID, t.Title, t.Description, t.Category, t.Priority, t.Status, t.DueDate)
if err != nil {
return fmt.Errorf("update task: %w", err)
}
return nil
}
// CompleteTask marks a task as completed with the current timestamp.
func (s *Store) CompleteTask(ctx context.Context, taskID uuid.UUID) error {
_, err := s.pool.Exec(ctx, `
UPDATE dsb_tasks
SET status = 'completed', completed_at = NOW(), updated_at = NOW()
WHERE id = $1
`, taskID)
if err != nil {
return fmt.Errorf("complete task: %w", err)
}
return nil
}
// ============================================================================
// Communications
// ============================================================================
// CreateCommunication inserts a new communication log entry.
func (s *Store) CreateCommunication(ctx context.Context, c *Communication) error {
c.ID = uuid.New()
c.CreatedAt = time.Now().UTC()
_, err := s.pool.Exec(ctx, `
INSERT INTO dsb_communications (id, assignment_id, direction, channel, subject, content, participants, created_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
`, c.ID, c.AssignmentID, c.Direction, c.Channel, c.Subject, c.Content, c.Participants, c.CreatedAt)
if err != nil {
return fmt.Errorf("insert communication: %w", err)
}
return nil
}
// ListCommunications returns all communication entries for an assignment.
func (s *Store) ListCommunications(ctx context.Context, assignmentID uuid.UUID) ([]Communication, error) {
rows, err := s.pool.Query(ctx, `
SELECT id, assignment_id, direction, channel, subject, content, participants, created_at
FROM dsb_communications
WHERE assignment_id = $1
ORDER BY created_at DESC
`, assignmentID)
if err != nil {
return nil, fmt.Errorf("query communications: %w", err)
}
defer rows.Close()
var comms []Communication
for rows.Next() {
var c Communication
if err := rows.Scan(
&c.ID, &c.AssignmentID, &c.Direction, &c.Channel,
&c.Subject, &c.Content, &c.Participants, &c.CreatedAt,
); err != nil {
return nil, fmt.Errorf("scan communication: %w", err)
}
comms = append(comms, c)
}
if comms == nil {
comms = []Communication{}
}
return comms, nil
}