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>
312 lines
8.9 KiB
Go
312 lines
8.9 KiB
Go
package portfolio
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
// ============================================================================
|
|
// 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 — 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
|
|
}
|