package portfolio import ( "context" "encoding/json" "fmt" "time" "github.com/google/uuid" "github.com/jackc/pgx/v5" ) // ============================================================================ // 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: result.ItemsSkipped++ result.ConflictsResolved = append(result.ConflictsResolved, MergeConflict{ ItemID: item.ItemID, ItemType: item.ItemType, Reason: "duplicate", Resolution: "kept_target", }) case MergeStrategyReplace: result.ItemsUpdated++ result.ConflictsResolved = append(result.ConflictsResolved, MergeConflict{ ItemID: item.ItemID, ItemType: item.ItemType, Reason: "duplicate", Resolution: "merged", }) case MergeStrategyIntersect: 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 { 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 }