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>
511 lines
15 KiB
Go
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
|
|
}
|