package portfolio import ( "context" "encoding/json" "fmt" "time" "github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" ) // Store handles portfolio data persistence type Store struct { pool *pgxpool.Pool } // NewStore creates a new portfolio store func NewStore(pool *pgxpool.Pool) *Store { return &Store{pool: pool} } // ============================================================================ // Portfolio CRUD Operations // ============================================================================ // CreatePortfolio creates a new portfolio func (s *Store) CreatePortfolio(ctx context.Context, p *Portfolio) error { p.ID = uuid.New() p.CreatedAt = time.Now().UTC() p.UpdatedAt = p.CreatedAt if p.Status == "" { p.Status = PortfolioStatusDraft } settings, _ := json.Marshal(p.Settings) _, err := s.pool.Exec(ctx, ` INSERT INTO portfolios ( id, tenant_id, namespace_id, name, description, status, department, business_unit, owner, owner_email, total_assessments, total_roadmaps, total_workshops, avg_risk_score, high_risk_count, conditional_count, approved_count, compliance_score, settings, created_at, updated_at, created_by, approved_at, approved_by ) 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 ) `, p.ID, p.TenantID, p.NamespaceID, p.Name, p.Description, string(p.Status), p.Department, p.BusinessUnit, p.Owner, p.OwnerEmail, p.TotalAssessments, p.TotalRoadmaps, p.TotalWorkshops, p.AvgRiskScore, p.HighRiskCount, p.ConditionalCount, p.ApprovedCount, p.ComplianceScore, settings, p.CreatedAt, p.UpdatedAt, p.CreatedBy, p.ApprovedAt, p.ApprovedBy, ) return err } // GetPortfolio retrieves a portfolio by ID func (s *Store) GetPortfolio(ctx context.Context, id uuid.UUID) (*Portfolio, error) { var p Portfolio var status string var settings []byte err := s.pool.QueryRow(ctx, ` SELECT id, tenant_id, namespace_id, name, description, status, department, business_unit, owner, owner_email, total_assessments, total_roadmaps, total_workshops, avg_risk_score, high_risk_count, conditional_count, approved_count, compliance_score, settings, created_at, updated_at, created_by, approved_at, approved_by FROM portfolios WHERE id = $1 `, id).Scan( &p.ID, &p.TenantID, &p.NamespaceID, &p.Name, &p.Description, &status, &p.Department, &p.BusinessUnit, &p.Owner, &p.OwnerEmail, &p.TotalAssessments, &p.TotalRoadmaps, &p.TotalWorkshops, &p.AvgRiskScore, &p.HighRiskCount, &p.ConditionalCount, &p.ApprovedCount, &p.ComplianceScore, &settings, &p.CreatedAt, &p.UpdatedAt, &p.CreatedBy, &p.ApprovedAt, &p.ApprovedBy, ) if err == pgx.ErrNoRows { return nil, nil } if err != nil { return nil, err } p.Status = PortfolioStatus(status) json.Unmarshal(settings, &p.Settings) return &p, nil } // ListPortfolios lists portfolios for a tenant with optional filters func (s *Store) ListPortfolios(ctx context.Context, tenantID uuid.UUID, filters *PortfolioFilters) ([]Portfolio, error) { query := ` SELECT id, tenant_id, namespace_id, name, description, status, department, business_unit, owner, owner_email, total_assessments, total_roadmaps, total_workshops, avg_risk_score, high_risk_count, conditional_count, approved_count, compliance_score, settings, created_at, updated_at, created_by, approved_at, approved_by FROM portfolios 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, string(filters.Status)) argIdx++ } if filters.Department != "" { query += fmt.Sprintf(" AND department = $%d", argIdx) args = append(args, filters.Department) argIdx++ } if filters.BusinessUnit != "" { query += fmt.Sprintf(" AND business_unit = $%d", argIdx) args = append(args, filters.BusinessUnit) argIdx++ } if filters.Owner != "" { query += fmt.Sprintf(" AND owner ILIKE $%d", argIdx) args = append(args, "%"+filters.Owner+"%") argIdx++ } if filters.MinRiskScore != nil { query += fmt.Sprintf(" AND avg_risk_score >= $%d", argIdx) args = append(args, *filters.MinRiskScore) argIdx++ } if filters.MaxRiskScore != nil { query += fmt.Sprintf(" AND avg_risk_score <= $%d", argIdx) args = append(args, *filters.MaxRiskScore) argIdx++ } } query += " ORDER BY updated_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 portfolios []Portfolio for rows.Next() { var p Portfolio var status string var settings []byte err := rows.Scan( &p.ID, &p.TenantID, &p.NamespaceID, &p.Name, &p.Description, &status, &p.Department, &p.BusinessUnit, &p.Owner, &p.OwnerEmail, &p.TotalAssessments, &p.TotalRoadmaps, &p.TotalWorkshops, &p.AvgRiskScore, &p.HighRiskCount, &p.ConditionalCount, &p.ApprovedCount, &p.ComplianceScore, &settings, &p.CreatedAt, &p.UpdatedAt, &p.CreatedBy, &p.ApprovedAt, &p.ApprovedBy, ) if err != nil { return nil, err } p.Status = PortfolioStatus(status) json.Unmarshal(settings, &p.Settings) portfolios = append(portfolios, p) } return portfolios, nil } // UpdatePortfolio updates a portfolio func (s *Store) UpdatePortfolio(ctx context.Context, p *Portfolio) error { p.UpdatedAt = time.Now().UTC() settings, _ := json.Marshal(p.Settings) _, err := s.pool.Exec(ctx, ` UPDATE portfolios SET name = $2, description = $3, status = $4, department = $5, business_unit = $6, owner = $7, owner_email = $8, settings = $9, updated_at = $10, approved_at = $11, approved_by = $12 WHERE id = $1 `, p.ID, p.Name, p.Description, string(p.Status), p.Department, p.BusinessUnit, p.Owner, p.OwnerEmail, settings, p.UpdatedAt, p.ApprovedAt, p.ApprovedBy, ) return err } // DeletePortfolio deletes a portfolio and its items func (s *Store) DeletePortfolio(ctx context.Context, id uuid.UUID) error { // Delete items first _, err := s.pool.Exec(ctx, "DELETE FROM portfolio_items WHERE portfolio_id = $1", id) if err != nil { return err } // Delete portfolio _, err = s.pool.Exec(ctx, "DELETE FROM portfolios WHERE id = $1", id) return err } // ============================================================================ // Portfolio Item Operations // ============================================================================ // AddItem adds an item to a portfolio func (s *Store) AddItem(ctx context.Context, item *PortfolioItem) error { item.ID = uuid.New() item.AddedAt = time.Now().UTC() tags, _ := json.Marshal(item.Tags) // Check if item already exists in portfolio var exists bool s.pool.QueryRow(ctx, "SELECT EXISTS(SELECT 1 FROM portfolio_items WHERE portfolio_id = $1 AND item_id = $2)", item.PortfolioID, item.ItemID).Scan(&exists) if exists { return fmt.Errorf("item already exists in portfolio") } // Get max sort order var maxSort int s.pool.QueryRow(ctx, "SELECT COALESCE(MAX(sort_order), 0) FROM portfolio_items WHERE portfolio_id = $1", item.PortfolioID).Scan(&maxSort) item.SortOrder = maxSort + 1 _, err := s.pool.Exec(ctx, ` INSERT INTO portfolio_items ( id, portfolio_id, item_type, item_id, title, status, risk_level, risk_score, feasibility, sort_order, tags, notes, added_at, added_by ) VALUES ( $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14 ) `, item.ID, item.PortfolioID, string(item.ItemType), item.ItemID, item.Title, item.Status, item.RiskLevel, item.RiskScore, item.Feasibility, item.SortOrder, tags, item.Notes, item.AddedAt, item.AddedBy, ) if err != nil { return err } // Update portfolio metrics return s.RecalculateMetrics(ctx, item.PortfolioID) } // GetItem retrieves a portfolio item by ID func (s *Store) GetItem(ctx context.Context, id uuid.UUID) (*PortfolioItem, error) { var item PortfolioItem var itemType string var tags []byte err := s.pool.QueryRow(ctx, ` SELECT id, portfolio_id, item_type, item_id, title, status, risk_level, risk_score, feasibility, sort_order, tags, notes, added_at, added_by FROM portfolio_items WHERE id = $1 `, id).Scan( &item.ID, &item.PortfolioID, &itemType, &item.ItemID, &item.Title, &item.Status, &item.RiskLevel, &item.RiskScore, &item.Feasibility, &item.SortOrder, &tags, &item.Notes, &item.AddedAt, &item.AddedBy, ) if err == pgx.ErrNoRows { return nil, nil } if err != nil { return nil, err } item.ItemType = ItemType(itemType) json.Unmarshal(tags, &item.Tags) return &item, nil } // ListItems lists items in a portfolio func (s *Store) ListItems(ctx context.Context, portfolioID uuid.UUID, itemType *ItemType) ([]PortfolioItem, error) { query := ` SELECT id, portfolio_id, item_type, item_id, title, status, risk_level, risk_score, feasibility, sort_order, tags, notes, added_at, added_by FROM portfolio_items WHERE portfolio_id = $1` args := []interface{}{portfolioID} if itemType != nil { query += " AND item_type = $2" args = append(args, string(*itemType)) } query += " ORDER BY sort_order ASC" rows, err := s.pool.Query(ctx, query, args...) if err != nil { return nil, err } defer rows.Close() var items []PortfolioItem for rows.Next() { var item PortfolioItem var iType string var tags []byte err := rows.Scan( &item.ID, &item.PortfolioID, &iType, &item.ItemID, &item.Title, &item.Status, &item.RiskLevel, &item.RiskScore, &item.Feasibility, &item.SortOrder, &tags, &item.Notes, &item.AddedAt, &item.AddedBy, ) if err != nil { return nil, err } item.ItemType = ItemType(iType) json.Unmarshal(tags, &item.Tags) items = append(items, item) } return items, nil } // RemoveItem removes an item from a portfolio func (s *Store) RemoveItem(ctx context.Context, id uuid.UUID) error { // Get portfolio ID first var portfolioID uuid.UUID err := s.pool.QueryRow(ctx, "SELECT portfolio_id FROM portfolio_items WHERE id = $1", id).Scan(&portfolioID) if err != nil { return err } _, err = s.pool.Exec(ctx, "DELETE FROM portfolio_items WHERE id = $1", id) if err != nil { return err } // Recalculate metrics return s.RecalculateMetrics(ctx, portfolioID) } // UpdateItemOrder updates the sort order of items func (s *Store) UpdateItemOrder(ctx context.Context, portfolioID uuid.UUID, itemIDs []uuid.UUID) error { for i, id := range itemIDs { _, err := s.pool.Exec(ctx, "UPDATE portfolio_items SET sort_order = $2 WHERE id = $1 AND portfolio_id = $3", id, i+1, portfolioID) if err != nil { return err } } return nil } // ============================================================================ // Merge Operations // ============================================================================ // MergePortfolios merges source portfolio into target func (s *Store) MergePortfolios(ctx context.Context, req *MergeRequest, userID uuid.UUID) (*MergeResult, error) { result := &MergeResult{ ConflictsResolved: []MergeConflict{}, } // Get source items sourceItems, err := s.ListItems(ctx, req.SourcePortfolioID, nil) if err != nil { return nil, fmt.Errorf("failed to get source items: %w", err) } // Get target items for conflict detection targetItems, err := s.ListItems(ctx, req.TargetPortfolioID, nil) if err != nil { return nil, fmt.Errorf("failed to get target items: %w", err) } // Build map of existing items in target targetItemMap := make(map[uuid.UUID]bool) for _, item := range targetItems { targetItemMap[item.ItemID] = true } // Filter items based on strategy and options for _, item := range sourceItems { // Skip if not including this type if item.ItemType == ItemTypeRoadmap && !req.IncludeRoadmaps { result.ItemsSkipped++ continue } if item.ItemType == ItemTypeWorkshop && !req.IncludeWorkshops { result.ItemsSkipped++ continue } // Check for conflicts if targetItemMap[item.ItemID] { switch req.Strategy { case MergeStrategyUnion: // Skip duplicates result.ItemsSkipped++ result.ConflictsResolved = append(result.ConflictsResolved, MergeConflict{ ItemID: item.ItemID, ItemType: item.ItemType, Reason: "duplicate", Resolution: "kept_target", }) case MergeStrategyReplace: // Update existing item in target // For now, just skip (could implement update logic) result.ItemsUpdated++ result.ConflictsResolved = append(result.ConflictsResolved, MergeConflict{ ItemID: item.ItemID, ItemType: item.ItemType, Reason: "duplicate", Resolution: "merged", }) case MergeStrategyIntersect: // Keep only items that exist in both // Skip items not in target result.ItemsSkipped++ } continue } // Add item to target newItem := &PortfolioItem{ PortfolioID: req.TargetPortfolioID, ItemType: item.ItemType, ItemID: item.ItemID, Title: item.Title, Status: item.Status, RiskLevel: item.RiskLevel, RiskScore: item.RiskScore, Feasibility: item.Feasibility, Tags: item.Tags, Notes: item.Notes, AddedBy: userID, } if err := s.AddItem(ctx, newItem); err != nil { // Skip on error but continue result.ItemsSkipped++ continue } result.ItemsAdded++ } // Delete source if requested if req.DeleteSource { if err := s.DeletePortfolio(ctx, req.SourcePortfolioID); err != nil { return nil, fmt.Errorf("failed to delete source portfolio: %w", err) } result.SourceDeleted = true } // Get updated target portfolio result.TargetPortfolio, _ = s.GetPortfolio(ctx, req.TargetPortfolioID) return result, nil } // ============================================================================ // Metrics and Statistics // ============================================================================ // RecalculateMetrics recalculates aggregated metrics for a portfolio func (s *Store) RecalculateMetrics(ctx context.Context, portfolioID uuid.UUID) error { // Count by type var totalAssessments, totalRoadmaps, totalWorkshops int s.pool.QueryRow(ctx, "SELECT COUNT(*) FROM portfolio_items WHERE portfolio_id = $1 AND item_type = 'ASSESSMENT'", portfolioID).Scan(&totalAssessments) s.pool.QueryRow(ctx, "SELECT COUNT(*) FROM portfolio_items WHERE portfolio_id = $1 AND item_type = 'ROADMAP'", portfolioID).Scan(&totalRoadmaps) s.pool.QueryRow(ctx, "SELECT COUNT(*) FROM portfolio_items WHERE portfolio_id = $1 AND item_type = 'WORKSHOP'", portfolioID).Scan(&totalWorkshops) // Calculate risk metrics from assessments var avgRiskScore float64 var highRiskCount, conditionalCount, approvedCount int s.pool.QueryRow(ctx, ` SELECT COALESCE(AVG(risk_score), 0) FROM portfolio_items WHERE portfolio_id = $1 AND item_type = 'ASSESSMENT' `, portfolioID).Scan(&avgRiskScore) s.pool.QueryRow(ctx, ` SELECT COUNT(*) FROM portfolio_items WHERE portfolio_id = $1 AND item_type = 'ASSESSMENT' AND risk_level IN ('HIGH', 'UNACCEPTABLE') `, portfolioID).Scan(&highRiskCount) s.pool.QueryRow(ctx, ` SELECT COUNT(*) FROM portfolio_items WHERE portfolio_id = $1 AND item_type = 'ASSESSMENT' AND feasibility = 'CONDITIONAL' `, portfolioID).Scan(&conditionalCount) s.pool.QueryRow(ctx, ` SELECT COUNT(*) FROM portfolio_items WHERE portfolio_id = $1 AND item_type = 'ASSESSMENT' AND feasibility = 'YES' `, portfolioID).Scan(&approvedCount) // Calculate compliance score (simplified: % of approved items) var complianceScore float64 if totalAssessments > 0 { complianceScore = (float64(approvedCount) / float64(totalAssessments)) * 100 } // Update portfolio _, err := s.pool.Exec(ctx, ` UPDATE portfolios SET total_assessments = $2, total_roadmaps = $3, total_workshops = $4, avg_risk_score = $5, high_risk_count = $6, conditional_count = $7, approved_count = $8, compliance_score = $9, updated_at = NOW() WHERE id = $1 `, portfolioID, totalAssessments, totalRoadmaps, totalWorkshops, avgRiskScore, highRiskCount, conditionalCount, approvedCount, complianceScore, ) return err } // GetPortfolioStats returns detailed statistics for a portfolio func (s *Store) GetPortfolioStats(ctx context.Context, portfolioID uuid.UUID) (*PortfolioStats, error) { stats := &PortfolioStats{ ItemsByType: make(map[ItemType]int), } // Total items s.pool.QueryRow(ctx, "SELECT COUNT(*) FROM portfolio_items WHERE portfolio_id = $1", portfolioID).Scan(&stats.TotalItems) // Items by type rows, _ := s.pool.Query(ctx, ` SELECT item_type, COUNT(*) FROM portfolio_items WHERE portfolio_id = $1 GROUP BY item_type `, portfolioID) if rows != nil { defer rows.Close() for rows.Next() { var itemType string var count int rows.Scan(&itemType, &count) stats.ItemsByType[ItemType(itemType)] = count } } // Risk distribution s.pool.QueryRow(ctx, ` SELECT COUNT(*) FROM portfolio_items WHERE portfolio_id = $1 AND risk_level = 'MINIMAL' `, portfolioID).Scan(&stats.RiskDistribution.Minimal) s.pool.QueryRow(ctx, ` SELECT COUNT(*) FROM portfolio_items WHERE portfolio_id = $1 AND risk_level = 'LOW' `, portfolioID).Scan(&stats.RiskDistribution.Low) s.pool.QueryRow(ctx, ` SELECT COUNT(*) FROM portfolio_items WHERE portfolio_id = $1 AND risk_level = 'MEDIUM' `, portfolioID).Scan(&stats.RiskDistribution.Medium) s.pool.QueryRow(ctx, ` SELECT COUNT(*) FROM portfolio_items WHERE portfolio_id = $1 AND risk_level = 'HIGH' `, portfolioID).Scan(&stats.RiskDistribution.High) s.pool.QueryRow(ctx, ` SELECT COUNT(*) FROM portfolio_items WHERE portfolio_id = $1 AND risk_level = 'UNACCEPTABLE' `, portfolioID).Scan(&stats.RiskDistribution.Unacceptable) // Feasibility distribution s.pool.QueryRow(ctx, ` SELECT COUNT(*) FROM portfolio_items WHERE portfolio_id = $1 AND feasibility = 'YES' `, portfolioID).Scan(&stats.FeasibilityDist.Yes) s.pool.QueryRow(ctx, ` SELECT COUNT(*) FROM portfolio_items WHERE portfolio_id = $1 AND feasibility = 'CONDITIONAL' `, portfolioID).Scan(&stats.FeasibilityDist.Conditional) s.pool.QueryRow(ctx, ` SELECT COUNT(*) FROM portfolio_items WHERE portfolio_id = $1 AND feasibility = 'NO' `, portfolioID).Scan(&stats.FeasibilityDist.No) // Average risk score s.pool.QueryRow(ctx, ` SELECT COALESCE(AVG(risk_score), 0) FROM portfolio_items WHERE portfolio_id = $1 AND item_type = 'ASSESSMENT' `, portfolioID).Scan(&stats.AvgRiskScore) // Compliance score s.pool.QueryRow(ctx, "SELECT compliance_score FROM portfolios WHERE id = $1", portfolioID).Scan(&stats.ComplianceScore) // DSFA required count // This would need to join with ucca_assessments to get dsfa_recommended // For now, estimate from high risk items stats.DSFARequired = stats.RiskDistribution.High + stats.RiskDistribution.Unacceptable // Controls required (items with CONDITIONAL feasibility) stats.ControlsRequired = stats.FeasibilityDist.Conditional stats.LastUpdated = time.Now().UTC() return stats, nil } // GetPortfolioSummary returns a complete portfolio summary func (s *Store) GetPortfolioSummary(ctx context.Context, portfolioID uuid.UUID) (*PortfolioSummary, error) { portfolio, err := s.GetPortfolio(ctx, portfolioID) if err != nil || portfolio == nil { return nil, err } items, err := s.ListItems(ctx, portfolioID, nil) if err != nil { return nil, err } stats, err := s.GetPortfolioStats(ctx, portfolioID) if err != nil { return nil, err } return &PortfolioSummary{ Portfolio: portfolio, Items: items, RiskDistribution: stats.RiskDistribution, FeasibilityDist: stats.FeasibilityDist, }, nil } // ============================================================================ // Bulk Operations // ============================================================================ // BulkAddItems adds multiple items to a portfolio func (s *Store) BulkAddItems(ctx context.Context, portfolioID uuid.UUID, items []PortfolioItem, userID uuid.UUID) (*BulkAddItemsResponse, error) { result := &BulkAddItemsResponse{ Errors: []string{}, } for _, item := range items { item.PortfolioID = portfolioID item.AddedBy = userID // Fetch item info from source table if not provided if item.Title == "" { s.populateItemInfo(ctx, &item) } if err := s.AddItem(ctx, &item); err != nil { result.Skipped++ result.Errors = append(result.Errors, fmt.Sprintf("%s: %v", item.ItemID, err)) } else { result.Added++ } } return result, nil } // populateItemInfo fetches item metadata from the source table func (s *Store) populateItemInfo(ctx context.Context, item *PortfolioItem) { switch item.ItemType { case ItemTypeAssessment: s.pool.QueryRow(ctx, ` SELECT title, feasibility, risk_level, risk_score, status FROM ucca_assessments WHERE id = $1 `, item.ItemID).Scan(&item.Title, &item.Feasibility, &item.RiskLevel, &item.RiskScore, &item.Status) case ItemTypeRoadmap: s.pool.QueryRow(ctx, ` SELECT name, status FROM roadmaps WHERE id = $1 `, item.ItemID).Scan(&item.Title, &item.Status) case ItemTypeWorkshop: s.pool.QueryRow(ctx, ` SELECT title, status FROM workshop_sessions WHERE id = $1 `, item.ItemID).Scan(&item.Title, &item.Status) } } // ============================================================================ // Activity Tracking // ============================================================================ // LogActivity logs an activity entry for a portfolio func (s *Store) LogActivity(ctx context.Context, portfolioID uuid.UUID, entry *ActivityEntry) error { _, err := s.pool.Exec(ctx, ` INSERT INTO portfolio_activity ( id, portfolio_id, timestamp, action, item_type, item_id, item_title, user_id ) VALUES ( $1, $2, $3, $4, $5, $6, $7, $8 ) `, uuid.New(), portfolioID, entry.Timestamp, entry.Action, string(entry.ItemType), entry.ItemID, entry.ItemTitle, entry.UserID, ) return err } // GetRecentActivity retrieves recent activity for a portfolio func (s *Store) GetRecentActivity(ctx context.Context, portfolioID uuid.UUID, limit int) ([]ActivityEntry, error) { if limit <= 0 { limit = 20 } rows, err := s.pool.Query(ctx, ` SELECT timestamp, action, item_type, item_id, item_title, user_id FROM portfolio_activity WHERE portfolio_id = $1 ORDER BY timestamp DESC LIMIT $2 `, portfolioID, limit) if err != nil { return nil, err } defer rows.Close() var activities []ActivityEntry for rows.Next() { var entry ActivityEntry var itemType string err := rows.Scan( &entry.Timestamp, &entry.Action, &itemType, &entry.ItemID, &entry.ItemTitle, &entry.UserID, ) if err != nil { return nil, err } entry.ItemType = ItemType(itemType) activities = append(activities, entry) } return activities, nil }