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:
510
ai-compliance-sdk/internal/dsb/store.go
Normal file
510
ai-compliance-sdk/internal/dsb/store.go
Normal file
@@ -0,0 +1,510 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user