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 }