package roadmap import ( "context" "encoding/json" "fmt" "time" "github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" ) // Store handles roadmap data persistence type Store struct { pool *pgxpool.Pool } // NewStore creates a new roadmap store func NewStore(pool *pgxpool.Pool) *Store { return &Store{pool: pool} } // ============================================================================ // Roadmap CRUD Operations // ============================================================================ // CreateRoadmap creates a new roadmap func (s *Store) CreateRoadmap(ctx context.Context, r *Roadmap) error { r.ID = uuid.New() r.CreatedAt = time.Now().UTC() r.UpdatedAt = r.CreatedAt if r.Status == "" { r.Status = "draft" } if r.Version == "" { r.Version = "1.0" } _, err := s.pool.Exec(ctx, ` INSERT INTO roadmaps ( id, tenant_id, namespace_id, title, description, version, assessment_id, portfolio_id, status, total_items, completed_items, progress, start_date, target_date, created_at, updated_at, created_by ) VALUES ( $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17 ) `, r.ID, r.TenantID, r.NamespaceID, r.Title, r.Description, r.Version, r.AssessmentID, r.PortfolioID, r.Status, r.TotalItems, r.CompletedItems, r.Progress, r.StartDate, r.TargetDate, r.CreatedAt, r.UpdatedAt, r.CreatedBy, ) return err } // GetRoadmap retrieves a roadmap by ID func (s *Store) GetRoadmap(ctx context.Context, id uuid.UUID) (*Roadmap, error) { var r Roadmap err := s.pool.QueryRow(ctx, ` SELECT id, tenant_id, namespace_id, title, description, version, assessment_id, portfolio_id, status, total_items, completed_items, progress, start_date, target_date, created_at, updated_at, created_by FROM roadmaps WHERE id = $1 `, id).Scan( &r.ID, &r.TenantID, &r.NamespaceID, &r.Title, &r.Description, &r.Version, &r.AssessmentID, &r.PortfolioID, &r.Status, &r.TotalItems, &r.CompletedItems, &r.Progress, &r.StartDate, &r.TargetDate, &r.CreatedAt, &r.UpdatedAt, &r.CreatedBy, ) if err == pgx.ErrNoRows { return nil, nil } if err != nil { return nil, err } return &r, nil } // ListRoadmaps lists roadmaps for a tenant with optional filters func (s *Store) ListRoadmaps(ctx context.Context, tenantID uuid.UUID, filters *RoadmapFilters) ([]Roadmap, error) { query := ` SELECT id, tenant_id, namespace_id, title, description, version, assessment_id, portfolio_id, status, total_items, completed_items, progress, start_date, target_date, created_at, updated_at, created_by FROM roadmaps WHERE tenant_id = $1` args := []interface{}{tenantID} argIdx := 2 if filters != nil { if filters.Status != "" { query += fmt.Sprintf(" AND status = $%d", argIdx) args = append(args, filters.Status) argIdx++ } if filters.AssessmentID != nil { query += fmt.Sprintf(" AND assessment_id = $%d", argIdx) args = append(args, *filters.AssessmentID) argIdx++ } if filters.PortfolioID != nil { query += fmt.Sprintf(" AND portfolio_id = $%d", argIdx) args = append(args, *filters.PortfolioID) argIdx++ } } query += " ORDER BY created_at DESC" if filters != nil && filters.Limit > 0 { query += fmt.Sprintf(" LIMIT $%d", argIdx) args = append(args, filters.Limit) argIdx++ if filters.Offset > 0 { query += fmt.Sprintf(" OFFSET $%d", argIdx) args = append(args, filters.Offset) } } rows, err := s.pool.Query(ctx, query, args...) if err != nil { return nil, err } defer rows.Close() var roadmaps []Roadmap for rows.Next() { var r Roadmap err := rows.Scan( &r.ID, &r.TenantID, &r.NamespaceID, &r.Title, &r.Description, &r.Version, &r.AssessmentID, &r.PortfolioID, &r.Status, &r.TotalItems, &r.CompletedItems, &r.Progress, &r.StartDate, &r.TargetDate, &r.CreatedAt, &r.UpdatedAt, &r.CreatedBy, ) if err != nil { return nil, err } roadmaps = append(roadmaps, r) } return roadmaps, nil } // UpdateRoadmap updates a roadmap func (s *Store) UpdateRoadmap(ctx context.Context, r *Roadmap) error { r.UpdatedAt = time.Now().UTC() _, err := s.pool.Exec(ctx, ` UPDATE roadmaps SET title = $2, description = $3, version = $4, assessment_id = $5, portfolio_id = $6, status = $7, total_items = $8, completed_items = $9, progress = $10, start_date = $11, target_date = $12, updated_at = $13 WHERE id = $1 `, r.ID, r.Title, r.Description, r.Version, r.AssessmentID, r.PortfolioID, r.Status, r.TotalItems, r.CompletedItems, r.Progress, r.StartDate, r.TargetDate, r.UpdatedAt, ) return err } // DeleteRoadmap deletes a roadmap and its items func (s *Store) DeleteRoadmap(ctx context.Context, id uuid.UUID) error { // Delete items first _, err := s.pool.Exec(ctx, "DELETE FROM roadmap_items WHERE roadmap_id = $1", id) if err != nil { return err } // Delete roadmap _, err = s.pool.Exec(ctx, "DELETE FROM roadmaps WHERE id = $1", id) return err } // ============================================================================ // RoadmapItem CRUD Operations // ============================================================================ // CreateItem creates a new roadmap item func (s *Store) CreateItem(ctx context.Context, item *RoadmapItem) error { item.ID = uuid.New() item.CreatedAt = time.Now().UTC() item.UpdatedAt = item.CreatedAt if item.Status == "" { item.Status = ItemStatusPlanned } if item.Priority == "" { item.Priority = ItemPriorityMedium } if item.Category == "" { item.Category = ItemCategoryTechnical } dependsOn, _ := json.Marshal(item.DependsOn) blockedBy, _ := json.Marshal(item.BlockedBy) evidenceReq, _ := json.Marshal(item.EvidenceRequired) evidenceProv, _ := json.Marshal(item.EvidenceProvided) _, err := s.pool.Exec(ctx, ` INSERT INTO roadmap_items ( id, roadmap_id, title, description, category, priority, status, control_id, regulation_ref, gap_id, effort_days, effort_hours, estimated_cost, assignee_id, assignee_name, department, planned_start, planned_end, actual_start, actual_end, depends_on, blocked_by, evidence_required, evidence_provided, notes, risk_notes, source_row, source_file, sort_order, created_at, updated_at ) VALUES ( $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31 ) `, item.ID, item.RoadmapID, item.Title, item.Description, string(item.Category), string(item.Priority), string(item.Status), item.ControlID, item.RegulationRef, item.GapID, item.EffortDays, item.EffortHours, item.EstimatedCost, item.AssigneeID, item.AssigneeName, item.Department, item.PlannedStart, item.PlannedEnd, item.ActualStart, item.ActualEnd, dependsOn, blockedBy, evidenceReq, evidenceProv, item.Notes, item.RiskNotes, item.SourceRow, item.SourceFile, item.SortOrder, item.CreatedAt, item.UpdatedAt, ) return err } // GetItem retrieves a roadmap item by ID func (s *Store) GetItem(ctx context.Context, id uuid.UUID) (*RoadmapItem, error) { var item RoadmapItem var category, priority, status string var dependsOn, blockedBy, evidenceReq, evidenceProv []byte err := s.pool.QueryRow(ctx, ` SELECT id, roadmap_id, title, description, category, priority, status, control_id, regulation_ref, gap_id, effort_days, effort_hours, estimated_cost, assignee_id, assignee_name, department, planned_start, planned_end, actual_start, actual_end, depends_on, blocked_by, evidence_required, evidence_provided, notes, risk_notes, source_row, source_file, sort_order, created_at, updated_at FROM roadmap_items WHERE id = $1 `, id).Scan( &item.ID, &item.RoadmapID, &item.Title, &item.Description, &category, &priority, &status, &item.ControlID, &item.RegulationRef, &item.GapID, &item.EffortDays, &item.EffortHours, &item.EstimatedCost, &item.AssigneeID, &item.AssigneeName, &item.Department, &item.PlannedStart, &item.PlannedEnd, &item.ActualStart, &item.ActualEnd, &dependsOn, &blockedBy, &evidenceReq, &evidenceProv, &item.Notes, &item.RiskNotes, &item.SourceRow, &item.SourceFile, &item.SortOrder, &item.CreatedAt, &item.UpdatedAt, ) if err == pgx.ErrNoRows { return nil, nil } if err != nil { return nil, err } item.Category = ItemCategory(category) item.Priority = ItemPriority(priority) item.Status = ItemStatus(status) json.Unmarshal(dependsOn, &item.DependsOn) json.Unmarshal(blockedBy, &item.BlockedBy) json.Unmarshal(evidenceReq, &item.EvidenceRequired) json.Unmarshal(evidenceProv, &item.EvidenceProvided) return &item, nil } // ListItems lists items for a roadmap with optional filters func (s *Store) ListItems(ctx context.Context, roadmapID uuid.UUID, filters *RoadmapItemFilters) ([]RoadmapItem, error) { query := ` SELECT id, roadmap_id, title, description, category, priority, status, control_id, regulation_ref, gap_id, effort_days, effort_hours, estimated_cost, assignee_id, assignee_name, department, planned_start, planned_end, actual_start, actual_end, depends_on, blocked_by, evidence_required, evidence_provided, notes, risk_notes, source_row, source_file, sort_order, created_at, updated_at FROM roadmap_items WHERE roadmap_id = $1` args := []interface{}{roadmapID} argIdx := 2 if filters != nil { if filters.Status != "" { query += fmt.Sprintf(" AND status = $%d", argIdx) args = append(args, string(filters.Status)) argIdx++ } if filters.Priority != "" { query += fmt.Sprintf(" AND priority = $%d", argIdx) args = append(args, string(filters.Priority)) argIdx++ } if filters.Category != "" { query += fmt.Sprintf(" AND category = $%d", argIdx) args = append(args, string(filters.Category)) argIdx++ } if filters.AssigneeID != nil { query += fmt.Sprintf(" AND assignee_id = $%d", argIdx) args = append(args, *filters.AssigneeID) argIdx++ } if filters.ControlID != "" { query += fmt.Sprintf(" AND control_id = $%d", argIdx) args = append(args, filters.ControlID) argIdx++ } if filters.SearchQuery != "" { query += fmt.Sprintf(" AND (title ILIKE $%d OR description ILIKE $%d)", argIdx, argIdx) args = append(args, "%"+filters.SearchQuery+"%") argIdx++ } } query += " ORDER BY sort_order ASC, priority ASC, created_at ASC" if filters != nil && filters.Limit > 0 { query += fmt.Sprintf(" LIMIT $%d", argIdx) args = append(args, filters.Limit) argIdx++ if filters.Offset > 0 { query += fmt.Sprintf(" OFFSET $%d", argIdx) args = append(args, filters.Offset) } } rows, err := s.pool.Query(ctx, query, args...) if err != nil { return nil, err } defer rows.Close() var items []RoadmapItem for rows.Next() { var item RoadmapItem var category, priority, status string var dependsOn, blockedBy, evidenceReq, evidenceProv []byte err := rows.Scan( &item.ID, &item.RoadmapID, &item.Title, &item.Description, &category, &priority, &status, &item.ControlID, &item.RegulationRef, &item.GapID, &item.EffortDays, &item.EffortHours, &item.EstimatedCost, &item.AssigneeID, &item.AssigneeName, &item.Department, &item.PlannedStart, &item.PlannedEnd, &item.ActualStart, &item.ActualEnd, &dependsOn, &blockedBy, &evidenceReq, &evidenceProv, &item.Notes, &item.RiskNotes, &item.SourceRow, &item.SourceFile, &item.SortOrder, &item.CreatedAt, &item.UpdatedAt, ) if err != nil { return nil, err } item.Category = ItemCategory(category) item.Priority = ItemPriority(priority) item.Status = ItemStatus(status) json.Unmarshal(dependsOn, &item.DependsOn) json.Unmarshal(blockedBy, &item.BlockedBy) json.Unmarshal(evidenceReq, &item.EvidenceRequired) json.Unmarshal(evidenceProv, &item.EvidenceProvided) items = append(items, item) } return items, nil } // UpdateItem updates a roadmap item func (s *Store) UpdateItem(ctx context.Context, item *RoadmapItem) error { item.UpdatedAt = time.Now().UTC() dependsOn, _ := json.Marshal(item.DependsOn) blockedBy, _ := json.Marshal(item.BlockedBy) evidenceReq, _ := json.Marshal(item.EvidenceRequired) evidenceProv, _ := json.Marshal(item.EvidenceProvided) _, err := s.pool.Exec(ctx, ` UPDATE roadmap_items SET title = $2, description = $3, category = $4, priority = $5, status = $6, control_id = $7, regulation_ref = $8, gap_id = $9, effort_days = $10, effort_hours = $11, estimated_cost = $12, assignee_id = $13, assignee_name = $14, department = $15, planned_start = $16, planned_end = $17, actual_start = $18, actual_end = $19, depends_on = $20, blocked_by = $21, evidence_required = $22, evidence_provided = $23, notes = $24, risk_notes = $25, sort_order = $26, updated_at = $27 WHERE id = $1 `, item.ID, item.Title, item.Description, string(item.Category), string(item.Priority), string(item.Status), item.ControlID, item.RegulationRef, item.GapID, item.EffortDays, item.EffortHours, item.EstimatedCost, item.AssigneeID, item.AssigneeName, item.Department, item.PlannedStart, item.PlannedEnd, item.ActualStart, item.ActualEnd, dependsOn, blockedBy, evidenceReq, evidenceProv, item.Notes, item.RiskNotes, item.SortOrder, item.UpdatedAt, ) return err } // DeleteItem deletes a roadmap item func (s *Store) DeleteItem(ctx context.Context, id uuid.UUID) error { _, err := s.pool.Exec(ctx, "DELETE FROM roadmap_items WHERE id = $1", id) return err } // BulkCreateItems creates multiple items in a transaction func (s *Store) BulkCreateItems(ctx context.Context, items []RoadmapItem) error { tx, err := s.pool.Begin(ctx) if err != nil { return err } defer tx.Rollback(ctx) for i := range items { item := &items[i] item.ID = uuid.New() item.CreatedAt = time.Now().UTC() item.UpdatedAt = item.CreatedAt dependsOn, _ := json.Marshal(item.DependsOn) blockedBy, _ := json.Marshal(item.BlockedBy) evidenceReq, _ := json.Marshal(item.EvidenceRequired) evidenceProv, _ := json.Marshal(item.EvidenceProvided) _, err := tx.Exec(ctx, ` INSERT INTO roadmap_items ( id, roadmap_id, title, description, category, priority, status, control_id, regulation_ref, gap_id, effort_days, effort_hours, estimated_cost, assignee_id, assignee_name, department, planned_start, planned_end, actual_start, actual_end, depends_on, blocked_by, evidence_required, evidence_provided, notes, risk_notes, source_row, source_file, sort_order, created_at, updated_at ) VALUES ( $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31 ) `, item.ID, item.RoadmapID, item.Title, item.Description, string(item.Category), string(item.Priority), string(item.Status), item.ControlID, item.RegulationRef, item.GapID, item.EffortDays, item.EffortHours, item.EstimatedCost, item.AssigneeID, item.AssigneeName, item.Department, item.PlannedStart, item.PlannedEnd, item.ActualStart, item.ActualEnd, dependsOn, blockedBy, evidenceReq, evidenceProv, item.Notes, item.RiskNotes, item.SourceRow, item.SourceFile, item.SortOrder, item.CreatedAt, item.UpdatedAt, ) if err != nil { return err } } return tx.Commit(ctx) } // ============================================================================ // Import Job Operations // ============================================================================ // CreateImportJob creates a new import job func (s *Store) CreateImportJob(ctx context.Context, job *ImportJob) error { job.ID = uuid.New() job.CreatedAt = time.Now().UTC() job.UpdatedAt = job.CreatedAt if job.Status == "" { job.Status = "pending" } parsedItems, _ := json.Marshal(job.ParsedItems) _, err := s.pool.Exec(ctx, ` INSERT INTO roadmap_import_jobs ( id, tenant_id, roadmap_id, filename, format, file_size, content_type, status, error_message, total_rows, valid_rows, invalid_rows, imported_items, parsed_items, created_at, updated_at, completed_at, created_by ) VALUES ( $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18 ) `, job.ID, job.TenantID, job.RoadmapID, job.Filename, string(job.Format), job.FileSize, job.ContentType, job.Status, job.ErrorMessage, job.TotalRows, job.ValidRows, job.InvalidRows, job.ImportedItems, parsedItems, job.CreatedAt, job.UpdatedAt, job.CompletedAt, job.CreatedBy, ) return err } // GetImportJob retrieves an import job by ID func (s *Store) GetImportJob(ctx context.Context, id uuid.UUID) (*ImportJob, error) { var job ImportJob var format string var parsedItems []byte err := s.pool.QueryRow(ctx, ` SELECT id, tenant_id, roadmap_id, filename, format, file_size, content_type, status, error_message, total_rows, valid_rows, invalid_rows, imported_items, parsed_items, created_at, updated_at, completed_at, created_by FROM roadmap_import_jobs WHERE id = $1 `, id).Scan( &job.ID, &job.TenantID, &job.RoadmapID, &job.Filename, &format, &job.FileSize, &job.ContentType, &job.Status, &job.ErrorMessage, &job.TotalRows, &job.ValidRows, &job.InvalidRows, &job.ImportedItems, &parsedItems, &job.CreatedAt, &job.UpdatedAt, &job.CompletedAt, &job.CreatedBy, ) if err == pgx.ErrNoRows { return nil, nil } if err != nil { return nil, err } job.Format = ImportFormat(format) json.Unmarshal(parsedItems, &job.ParsedItems) return &job, nil } // UpdateImportJob updates an import job func (s *Store) UpdateImportJob(ctx context.Context, job *ImportJob) error { job.UpdatedAt = time.Now().UTC() parsedItems, _ := json.Marshal(job.ParsedItems) _, err := s.pool.Exec(ctx, ` UPDATE roadmap_import_jobs SET roadmap_id = $2, status = $3, error_message = $4, total_rows = $5, valid_rows = $6, invalid_rows = $7, imported_items = $8, parsed_items = $9, updated_at = $10, completed_at = $11 WHERE id = $1 `, job.ID, job.RoadmapID, job.Status, job.ErrorMessage, job.TotalRows, job.ValidRows, job.InvalidRows, job.ImportedItems, parsedItems, job.UpdatedAt, job.CompletedAt, ) return err } // ============================================================================ // Statistics // ============================================================================ // GetRoadmapStats returns statistics for a roadmap func (s *Store) GetRoadmapStats(ctx context.Context, roadmapID uuid.UUID) (*RoadmapStats, error) { stats := &RoadmapStats{ ByStatus: make(map[string]int), ByPriority: make(map[string]int), ByCategory: make(map[string]int), ByDepartment: make(map[string]int), } // Total count s.pool.QueryRow(ctx, "SELECT COUNT(*) FROM roadmap_items WHERE roadmap_id = $1", roadmapID).Scan(&stats.TotalItems) // By status rows, err := s.pool.Query(ctx, "SELECT status, COUNT(*) FROM roadmap_items WHERE roadmap_id = $1 GROUP BY status", roadmapID) if err == nil { defer rows.Close() for rows.Next() { var status string var count int rows.Scan(&status, &count) stats.ByStatus[status] = count } } // By priority rows, err = s.pool.Query(ctx, "SELECT priority, COUNT(*) FROM roadmap_items WHERE roadmap_id = $1 GROUP BY priority", roadmapID) if err == nil { defer rows.Close() for rows.Next() { var priority string var count int rows.Scan(&priority, &count) stats.ByPriority[priority] = count } } // By category rows, err = s.pool.Query(ctx, "SELECT category, COUNT(*) FROM roadmap_items WHERE roadmap_id = $1 GROUP BY category", roadmapID) if err == nil { defer rows.Close() for rows.Next() { var category string var count int rows.Scan(&category, &count) stats.ByCategory[category] = count } } // By department rows, err = s.pool.Query(ctx, "SELECT COALESCE(department, 'Unassigned'), COUNT(*) FROM roadmap_items WHERE roadmap_id = $1 GROUP BY department", roadmapID) if err == nil { defer rows.Close() for rows.Next() { var dept string var count int rows.Scan(&dept, &count) stats.ByDepartment[dept] = count } } // Overdue items s.pool.QueryRow(ctx, "SELECT COUNT(*) FROM roadmap_items WHERE roadmap_id = $1 AND planned_end < NOW() AND status NOT IN ('COMPLETED', 'DEFERRED')", roadmapID).Scan(&stats.OverdueItems) // Upcoming items (next 7 days) s.pool.QueryRow(ctx, "SELECT COUNT(*) FROM roadmap_items WHERE roadmap_id = $1 AND planned_end BETWEEN NOW() AND NOW() + INTERVAL '7 days' AND status NOT IN ('COMPLETED', 'DEFERRED')", roadmapID).Scan(&stats.UpcomingItems) // Total effort s.pool.QueryRow(ctx, "SELECT COALESCE(SUM(effort_days), 0) FROM roadmap_items WHERE roadmap_id = $1", roadmapID).Scan(&stats.TotalEffortDays) // Progress completedCount := stats.ByStatus[string(ItemStatusCompleted)] if stats.TotalItems > 0 { stats.Progress = (completedCount * 100) / stats.TotalItems } return stats, nil } // UpdateRoadmapProgress recalculates and updates roadmap progress func (s *Store) UpdateRoadmapProgress(ctx context.Context, roadmapID uuid.UUID) error { var total, completed int s.pool.QueryRow(ctx, "SELECT COUNT(*) FROM roadmap_items WHERE roadmap_id = $1", roadmapID).Scan(&total) s.pool.QueryRow(ctx, "SELECT COUNT(*) FROM roadmap_items WHERE roadmap_id = $1 AND status = 'COMPLETED'", roadmapID).Scan(&completed) progress := 0 if total > 0 { progress = (completed * 100) / total } _, err := s.pool.Exec(ctx, ` UPDATE roadmaps SET total_items = $2, completed_items = $3, progress = $4, updated_at = $5 WHERE id = $1 `, roadmapID, total, completed, progress, time.Now().UTC()) return err }