portfolio/store.go (818 LOC) → store_portfolio.go, store_items.go, store_metrics.go workshop/store.go (793 LOC) → store_sessions.go, store_participants.go, store_responses.go training/models.go (757 LOC) → models_enums.go, models_core.go, models_api.go, models_blocks.go roadmap/store.go (757 LOC) → store_roadmap.go, store_items.go, store_import.go All files under 350 LOC. Zero behavior changes, same package declarations. go vet passes on all five packages. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
282 lines
7.2 KiB
Go
282 lines
7.2 KiB
Go
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
|
|
}
|