Initial commit: breakpilot-compliance - Compliance SDK Platform
Services: Admin-Compliance, Backend-Compliance, AI-Compliance-SDK, Consent-SDK, Developer-Portal, PCA-Platform, DSMS Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
278
ai-compliance-sdk/internal/portfolio/models.go
Normal file
278
ai-compliance-sdk/internal/portfolio/models.go
Normal file
@@ -0,0 +1,278 @@
|
||||
package portfolio
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// ============================================================================
|
||||
// Constants / Enums
|
||||
// ============================================================================
|
||||
|
||||
// PortfolioStatus represents the status of a portfolio
|
||||
type PortfolioStatus string
|
||||
|
||||
const (
|
||||
PortfolioStatusDraft PortfolioStatus = "DRAFT"
|
||||
PortfolioStatusActive PortfolioStatus = "ACTIVE"
|
||||
PortfolioStatusReview PortfolioStatus = "REVIEW"
|
||||
PortfolioStatusApproved PortfolioStatus = "APPROVED"
|
||||
PortfolioStatusArchived PortfolioStatus = "ARCHIVED"
|
||||
)
|
||||
|
||||
// ItemType represents the type of item in a portfolio
|
||||
type ItemType string
|
||||
|
||||
const (
|
||||
ItemTypeAssessment ItemType = "ASSESSMENT"
|
||||
ItemTypeRoadmap ItemType = "ROADMAP"
|
||||
ItemTypeWorkshop ItemType = "WORKSHOP"
|
||||
ItemTypeDocument ItemType = "DOCUMENT"
|
||||
)
|
||||
|
||||
// MergeStrategy defines how to merge portfolios
|
||||
type MergeStrategy string
|
||||
|
||||
const (
|
||||
MergeStrategyUnion MergeStrategy = "UNION" // Combine all items (default)
|
||||
MergeStrategyIntersect MergeStrategy = "INTERSECT" // Only overlapping items
|
||||
MergeStrategyReplace MergeStrategy = "REPLACE" // Replace target with source
|
||||
)
|
||||
|
||||
// ============================================================================
|
||||
// Main Entities
|
||||
// ============================================================================
|
||||
|
||||
// Portfolio represents a collection of AI use case assessments and related artifacts
|
||||
type Portfolio struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
TenantID uuid.UUID `json:"tenant_id"`
|
||||
NamespaceID *uuid.UUID `json:"namespace_id,omitempty"`
|
||||
|
||||
// Info
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Status PortfolioStatus `json:"status"`
|
||||
|
||||
// Organization
|
||||
Department string `json:"department,omitempty"`
|
||||
BusinessUnit string `json:"business_unit,omitempty"`
|
||||
Owner string `json:"owner,omitempty"`
|
||||
OwnerEmail string `json:"owner_email,omitempty"`
|
||||
|
||||
// Aggregated metrics (computed)
|
||||
TotalAssessments int `json:"total_assessments"`
|
||||
TotalRoadmaps int `json:"total_roadmaps"`
|
||||
TotalWorkshops int `json:"total_workshops"`
|
||||
AvgRiskScore float64 `json:"avg_risk_score"`
|
||||
HighRiskCount int `json:"high_risk_count"`
|
||||
ConditionalCount int `json:"conditional_count"`
|
||||
ApprovedCount int `json:"approved_count"`
|
||||
ComplianceScore float64 `json:"compliance_score"` // 0-100
|
||||
|
||||
// Settings
|
||||
Settings PortfolioSettings `json:"settings"`
|
||||
|
||||
// Audit
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
CreatedBy uuid.UUID `json:"created_by"`
|
||||
ApprovedAt *time.Time `json:"approved_at,omitempty"`
|
||||
ApprovedBy *uuid.UUID `json:"approved_by,omitempty"`
|
||||
}
|
||||
|
||||
// PortfolioSettings contains configuration options
|
||||
type PortfolioSettings struct {
|
||||
AutoUpdateMetrics bool `json:"auto_update_metrics"` // Recalculate on changes
|
||||
RequireApproval bool `json:"require_approval"` // Require approval before active
|
||||
NotifyOnHighRisk bool `json:"notify_on_high_risk"` // Alert on high risk items
|
||||
AllowExternalShare bool `json:"allow_external_share"` // Share with external users
|
||||
}
|
||||
|
||||
// PortfolioItem represents an item linked to a portfolio
|
||||
type PortfolioItem struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
PortfolioID uuid.UUID `json:"portfolio_id"`
|
||||
ItemType ItemType `json:"item_type"`
|
||||
ItemID uuid.UUID `json:"item_id"` // Reference to the actual item
|
||||
|
||||
// Cached info from the linked item
|
||||
Title string `json:"title"`
|
||||
Status string `json:"status,omitempty"`
|
||||
RiskLevel string `json:"risk_level,omitempty"`
|
||||
RiskScore int `json:"risk_score,omitempty"`
|
||||
Feasibility string `json:"feasibility,omitempty"`
|
||||
|
||||
// Ordering and categorization
|
||||
SortOrder int `json:"sort_order"`
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
Notes string `json:"notes,omitempty"`
|
||||
|
||||
// Audit
|
||||
AddedAt time.Time `json:"added_at"`
|
||||
AddedBy uuid.UUID `json:"added_by"`
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Merge Operations
|
||||
// ============================================================================
|
||||
|
||||
// MergeRequest represents a request to merge portfolios
|
||||
type MergeRequest struct {
|
||||
SourcePortfolioID uuid.UUID `json:"source_portfolio_id"`
|
||||
TargetPortfolioID uuid.UUID `json:"target_portfolio_id"`
|
||||
Strategy MergeStrategy `json:"strategy"`
|
||||
DeleteSource bool `json:"delete_source"` // Delete source after merge
|
||||
IncludeRoadmaps bool `json:"include_roadmaps"`
|
||||
IncludeWorkshops bool `json:"include_workshops"`
|
||||
}
|
||||
|
||||
// MergeResult represents the result of a merge operation
|
||||
type MergeResult struct {
|
||||
TargetPortfolio *Portfolio `json:"target_portfolio"`
|
||||
ItemsAdded int `json:"items_added"`
|
||||
ItemsSkipped int `json:"items_skipped"` // Duplicates or excluded
|
||||
ItemsUpdated int `json:"items_updated"`
|
||||
SourceDeleted bool `json:"source_deleted"`
|
||||
ConflictsResolved []MergeConflict `json:"conflicts_resolved,omitempty"`
|
||||
}
|
||||
|
||||
// MergeConflict describes a conflict during merge
|
||||
type MergeConflict struct {
|
||||
ItemID uuid.UUID `json:"item_id"`
|
||||
ItemType ItemType `json:"item_type"`
|
||||
Reason string `json:"reason"`
|
||||
Resolution string `json:"resolution"` // "kept_source", "kept_target", "merged"
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Aggregated Views
|
||||
// ============================================================================
|
||||
|
||||
// PortfolioSummary contains aggregated portfolio information
|
||||
type PortfolioSummary struct {
|
||||
Portfolio *Portfolio `json:"portfolio"`
|
||||
Items []PortfolioItem `json:"items"`
|
||||
RiskDistribution RiskDistribution `json:"risk_distribution"`
|
||||
FeasibilityDist FeasibilityDist `json:"feasibility_distribution"`
|
||||
RecentActivity []ActivityEntry `json:"recent_activity,omitempty"`
|
||||
}
|
||||
|
||||
// RiskDistribution shows the distribution of risk levels
|
||||
type RiskDistribution struct {
|
||||
Minimal int `json:"minimal"`
|
||||
Low int `json:"low"`
|
||||
Medium int `json:"medium"`
|
||||
High int `json:"high"`
|
||||
Unacceptable int `json:"unacceptable"`
|
||||
}
|
||||
|
||||
// FeasibilityDist shows the distribution of feasibility verdicts
|
||||
type FeasibilityDist struct {
|
||||
Yes int `json:"yes"`
|
||||
Conditional int `json:"conditional"`
|
||||
No int `json:"no"`
|
||||
}
|
||||
|
||||
// ActivityEntry represents recent activity on a portfolio
|
||||
type ActivityEntry struct {
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
Action string `json:"action"` // "added", "removed", "updated", "merged"
|
||||
ItemType ItemType `json:"item_type"`
|
||||
ItemID uuid.UUID `json:"item_id"`
|
||||
ItemTitle string `json:"item_title"`
|
||||
UserID uuid.UUID `json:"user_id"`
|
||||
}
|
||||
|
||||
// PortfolioStats contains statistical information
|
||||
type PortfolioStats struct {
|
||||
TotalItems int `json:"total_items"`
|
||||
ItemsByType map[ItemType]int `json:"items_by_type"`
|
||||
RiskDistribution RiskDistribution `json:"risk_distribution"`
|
||||
FeasibilityDist FeasibilityDist `json:"feasibility_distribution"`
|
||||
AvgRiskScore float64 `json:"avg_risk_score"`
|
||||
ComplianceScore float64 `json:"compliance_score"`
|
||||
DSFARequired int `json:"dsfa_required"`
|
||||
ControlsRequired int `json:"controls_required"`
|
||||
LastUpdated time.Time `json:"last_updated"`
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// API Request/Response Types
|
||||
// ============================================================================
|
||||
|
||||
// CreatePortfolioRequest is the API request for creating a portfolio
|
||||
type CreatePortfolioRequest struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Department string `json:"department,omitempty"`
|
||||
BusinessUnit string `json:"business_unit,omitempty"`
|
||||
Owner string `json:"owner,omitempty"`
|
||||
OwnerEmail string `json:"owner_email,omitempty"`
|
||||
Settings PortfolioSettings `json:"settings,omitempty"`
|
||||
}
|
||||
|
||||
// UpdatePortfolioRequest is the API request for updating a portfolio
|
||||
type UpdatePortfolioRequest struct {
|
||||
Name string `json:"name,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Status PortfolioStatus `json:"status,omitempty"`
|
||||
Department string `json:"department,omitempty"`
|
||||
BusinessUnit string `json:"business_unit,omitempty"`
|
||||
Owner string `json:"owner,omitempty"`
|
||||
OwnerEmail string `json:"owner_email,omitempty"`
|
||||
Settings *PortfolioSettings `json:"settings,omitempty"`
|
||||
}
|
||||
|
||||
// AddItemRequest is the API request for adding an item to a portfolio
|
||||
type AddItemRequest struct {
|
||||
ItemType ItemType `json:"item_type"`
|
||||
ItemID uuid.UUID `json:"item_id"`
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
Notes string `json:"notes,omitempty"`
|
||||
}
|
||||
|
||||
// BulkAddItemsRequest is the API request for adding multiple items
|
||||
type BulkAddItemsRequest struct {
|
||||
Items []AddItemRequest `json:"items"`
|
||||
}
|
||||
|
||||
// BulkAddItemsResponse is the API response for bulk adding items
|
||||
type BulkAddItemsResponse struct {
|
||||
Added int `json:"added"`
|
||||
Skipped int `json:"skipped"`
|
||||
Errors []string `json:"errors,omitempty"`
|
||||
}
|
||||
|
||||
// PortfolioFilters defines filters for listing portfolios
|
||||
type PortfolioFilters struct {
|
||||
Status PortfolioStatus
|
||||
Department string
|
||||
BusinessUnit string
|
||||
Owner string
|
||||
MinRiskScore *float64
|
||||
MaxRiskScore *float64
|
||||
Limit int
|
||||
Offset int
|
||||
}
|
||||
|
||||
// ComparePortfoliosRequest is the API request for comparing portfolios
|
||||
type ComparePortfoliosRequest struct {
|
||||
PortfolioIDs []uuid.UUID `json:"portfolio_ids"` // 2-5 portfolios to compare
|
||||
}
|
||||
|
||||
// ComparePortfoliosResponse is the API response for portfolio comparison
|
||||
type ComparePortfoliosResponse struct {
|
||||
Portfolios []Portfolio `json:"portfolios"`
|
||||
Comparison PortfolioComparison `json:"comparison"`
|
||||
}
|
||||
|
||||
// PortfolioComparison contains comparative metrics
|
||||
type PortfolioComparison struct {
|
||||
RiskScores map[string]float64 `json:"risk_scores"` // portfolio_id -> score
|
||||
ComplianceScores map[string]float64 `json:"compliance_scores"`
|
||||
ItemCounts map[string]int `json:"item_counts"`
|
||||
CommonItems []uuid.UUID `json:"common_items"` // Items in multiple portfolios
|
||||
UniqueItems map[string][]uuid.UUID `json:"unique_items"` // portfolio_id -> item_ids
|
||||
}
|
||||
818
ai-compliance-sdk/internal/portfolio/store.go
Normal file
818
ai-compliance-sdk/internal/portfolio/store.go
Normal file
@@ -0,0 +1,818 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user