From 3fb5b949053b36c09767f628d5c4b1ad34ed89af Mon Sep 17 00:00:00 2001 From: Sharang Parnerkar <30073382+mighty840@users.noreply.github.com> Date: Sun, 19 Apr 2026 09:49:31 +0200 Subject: [PATCH] refactor(go): split portfolio, workshop, training/models, roadmap stores MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- ai-compliance-sdk/internal/portfolio/store.go | 818 ------------------ .../internal/portfolio/store_items.go | 281 ++++++ .../internal/portfolio/store_metrics.go | 311 +++++++ .../internal/portfolio/store_portfolio.go | 238 +++++ ai-compliance-sdk/internal/roadmap/store.go | 757 ---------------- .../internal/roadmap/store_import.go | 213 +++++ .../internal/roadmap/store_items.go | 337 ++++++++ .../internal/roadmap/store_roadmap.go | 227 +++++ ai-compliance-sdk/internal/training/models.go | 757 ---------------- .../internal/training/models_api.go | 141 +++ .../internal/training/models_blocks.go | 193 +++++ .../internal/training/models_core.go | 276 ++++++ .../internal/training/models_enums.go | 162 ++++ ai-compliance-sdk/internal/workshop/store.go | 793 ----------------- .../internal/workshop/store_participants.go | 225 +++++ .../internal/workshop/store_responses.go | 269 ++++++ .../internal/workshop/store_sessions.go | 317 +++++++ 17 files changed, 3190 insertions(+), 3125 deletions(-) delete mode 100644 ai-compliance-sdk/internal/portfolio/store.go create mode 100644 ai-compliance-sdk/internal/portfolio/store_items.go create mode 100644 ai-compliance-sdk/internal/portfolio/store_metrics.go create mode 100644 ai-compliance-sdk/internal/portfolio/store_portfolio.go delete mode 100644 ai-compliance-sdk/internal/roadmap/store.go create mode 100644 ai-compliance-sdk/internal/roadmap/store_import.go create mode 100644 ai-compliance-sdk/internal/roadmap/store_items.go create mode 100644 ai-compliance-sdk/internal/roadmap/store_roadmap.go delete mode 100644 ai-compliance-sdk/internal/training/models.go create mode 100644 ai-compliance-sdk/internal/training/models_api.go create mode 100644 ai-compliance-sdk/internal/training/models_blocks.go create mode 100644 ai-compliance-sdk/internal/training/models_core.go create mode 100644 ai-compliance-sdk/internal/training/models_enums.go delete mode 100644 ai-compliance-sdk/internal/workshop/store.go create mode 100644 ai-compliance-sdk/internal/workshop/store_participants.go create mode 100644 ai-compliance-sdk/internal/workshop/store_responses.go create mode 100644 ai-compliance-sdk/internal/workshop/store_sessions.go diff --git a/ai-compliance-sdk/internal/portfolio/store.go b/ai-compliance-sdk/internal/portfolio/store.go deleted file mode 100644 index 40f306b..0000000 --- a/ai-compliance-sdk/internal/portfolio/store.go +++ /dev/null @@ -1,818 +0,0 @@ -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 -} diff --git a/ai-compliance-sdk/internal/portfolio/store_items.go b/ai-compliance-sdk/internal/portfolio/store_items.go new file mode 100644 index 0000000..ae30961 --- /dev/null +++ b/ai-compliance-sdk/internal/portfolio/store_items.go @@ -0,0 +1,281 @@ +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 +} diff --git a/ai-compliance-sdk/internal/portfolio/store_metrics.go b/ai-compliance-sdk/internal/portfolio/store_metrics.go new file mode 100644 index 0000000..556f965 --- /dev/null +++ b/ai-compliance-sdk/internal/portfolio/store_metrics.go @@ -0,0 +1,311 @@ +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 +} diff --git a/ai-compliance-sdk/internal/portfolio/store_portfolio.go b/ai-compliance-sdk/internal/portfolio/store_portfolio.go new file mode 100644 index 0000000..aeabf20 --- /dev/null +++ b/ai-compliance-sdk/internal/portfolio/store_portfolio.go @@ -0,0 +1,238 @@ +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 +} diff --git a/ai-compliance-sdk/internal/roadmap/store.go b/ai-compliance-sdk/internal/roadmap/store.go deleted file mode 100644 index e5b76fb..0000000 --- a/ai-compliance-sdk/internal/roadmap/store.go +++ /dev/null @@ -1,757 +0,0 @@ -package roadmap - -import ( - "context" - "encoding/json" - "fmt" - "time" - - "github.com/google/uuid" - "github.com/jackc/pgx/v5" - "github.com/jackc/pgx/v5/pgxpool" -) - -// Store handles roadmap data persistence -type Store struct { - pool *pgxpool.Pool -} - -// NewStore creates a new roadmap store -func NewStore(pool *pgxpool.Pool) *Store { - return &Store{pool: pool} -} - -// ============================================================================ -// Roadmap CRUD Operations -// ============================================================================ - -// CreateRoadmap creates a new roadmap -func (s *Store) CreateRoadmap(ctx context.Context, r *Roadmap) error { - r.ID = uuid.New() - r.CreatedAt = time.Now().UTC() - r.UpdatedAt = r.CreatedAt - if r.Status == "" { - r.Status = "draft" - } - if r.Version == "" { - r.Version = "1.0" - } - - _, err := s.pool.Exec(ctx, ` - INSERT INTO roadmaps ( - id, tenant_id, namespace_id, title, description, version, - assessment_id, portfolio_id, status, - total_items, completed_items, progress, - start_date, target_date, - created_at, updated_at, created_by - ) VALUES ( - $1, $2, $3, $4, $5, $6, - $7, $8, $9, - $10, $11, $12, - $13, $14, - $15, $16, $17 - ) - `, - r.ID, r.TenantID, r.NamespaceID, r.Title, r.Description, r.Version, - r.AssessmentID, r.PortfolioID, r.Status, - r.TotalItems, r.CompletedItems, r.Progress, - r.StartDate, r.TargetDate, - r.CreatedAt, r.UpdatedAt, r.CreatedBy, - ) - - return err -} - -// GetRoadmap retrieves a roadmap by ID -func (s *Store) GetRoadmap(ctx context.Context, id uuid.UUID) (*Roadmap, error) { - var r Roadmap - - err := s.pool.QueryRow(ctx, ` - SELECT - id, tenant_id, namespace_id, title, description, version, - assessment_id, portfolio_id, status, - total_items, completed_items, progress, - start_date, target_date, - created_at, updated_at, created_by - FROM roadmaps WHERE id = $1 - `, id).Scan( - &r.ID, &r.TenantID, &r.NamespaceID, &r.Title, &r.Description, &r.Version, - &r.AssessmentID, &r.PortfolioID, &r.Status, - &r.TotalItems, &r.CompletedItems, &r.Progress, - &r.StartDate, &r.TargetDate, - &r.CreatedAt, &r.UpdatedAt, &r.CreatedBy, - ) - - if err == pgx.ErrNoRows { - return nil, nil - } - if err != nil { - return nil, err - } - - return &r, nil -} - -// ListRoadmaps lists roadmaps for a tenant with optional filters -func (s *Store) ListRoadmaps(ctx context.Context, tenantID uuid.UUID, filters *RoadmapFilters) ([]Roadmap, error) { - query := ` - SELECT - id, tenant_id, namespace_id, title, description, version, - assessment_id, portfolio_id, status, - total_items, completed_items, progress, - start_date, target_date, - created_at, updated_at, created_by - FROM roadmaps 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, filters.Status) - argIdx++ - } - if filters.AssessmentID != nil { - query += fmt.Sprintf(" AND assessment_id = $%d", argIdx) - args = append(args, *filters.AssessmentID) - argIdx++ - } - if filters.PortfolioID != nil { - query += fmt.Sprintf(" AND portfolio_id = $%d", argIdx) - args = append(args, *filters.PortfolioID) - argIdx++ - } - } - - query += " ORDER BY created_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 roadmaps []Roadmap - for rows.Next() { - var r Roadmap - err := rows.Scan( - &r.ID, &r.TenantID, &r.NamespaceID, &r.Title, &r.Description, &r.Version, - &r.AssessmentID, &r.PortfolioID, &r.Status, - &r.TotalItems, &r.CompletedItems, &r.Progress, - &r.StartDate, &r.TargetDate, - &r.CreatedAt, &r.UpdatedAt, &r.CreatedBy, - ) - if err != nil { - return nil, err - } - roadmaps = append(roadmaps, r) - } - - return roadmaps, nil -} - -// UpdateRoadmap updates a roadmap -func (s *Store) UpdateRoadmap(ctx context.Context, r *Roadmap) error { - r.UpdatedAt = time.Now().UTC() - - _, err := s.pool.Exec(ctx, ` - UPDATE roadmaps SET - title = $2, description = $3, version = $4, - assessment_id = $5, portfolio_id = $6, status = $7, - total_items = $8, completed_items = $9, progress = $10, - start_date = $11, target_date = $12, - updated_at = $13 - WHERE id = $1 - `, - r.ID, r.Title, r.Description, r.Version, - r.AssessmentID, r.PortfolioID, r.Status, - r.TotalItems, r.CompletedItems, r.Progress, - r.StartDate, r.TargetDate, - r.UpdatedAt, - ) - - return err -} - -// DeleteRoadmap deletes a roadmap and its items -func (s *Store) DeleteRoadmap(ctx context.Context, id uuid.UUID) error { - // Delete items first - _, err := s.pool.Exec(ctx, "DELETE FROM roadmap_items WHERE roadmap_id = $1", id) - if err != nil { - return err - } - - // Delete roadmap - _, err = s.pool.Exec(ctx, "DELETE FROM roadmaps WHERE id = $1", id) - return err -} - -// ============================================================================ -// RoadmapItem CRUD Operations -// ============================================================================ - -// CreateItem creates a new roadmap item -func (s *Store) CreateItem(ctx context.Context, item *RoadmapItem) error { - item.ID = uuid.New() - item.CreatedAt = time.Now().UTC() - item.UpdatedAt = item.CreatedAt - if item.Status == "" { - item.Status = ItemStatusPlanned - } - if item.Priority == "" { - item.Priority = ItemPriorityMedium - } - if item.Category == "" { - item.Category = ItemCategoryTechnical - } - - dependsOn, _ := json.Marshal(item.DependsOn) - blockedBy, _ := json.Marshal(item.BlockedBy) - evidenceReq, _ := json.Marshal(item.EvidenceRequired) - evidenceProv, _ := json.Marshal(item.EvidenceProvided) - - _, err := s.pool.Exec(ctx, ` - INSERT INTO roadmap_items ( - id, roadmap_id, title, description, category, priority, status, - control_id, regulation_ref, gap_id, - effort_days, effort_hours, estimated_cost, - assignee_id, assignee_name, department, - planned_start, planned_end, actual_start, actual_end, - depends_on, blocked_by, - evidence_required, evidence_provided, - notes, risk_notes, - source_row, source_file, sort_order, - created_at, updated_at - ) 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, - $25, $26, - $27, $28, $29, - $30, $31 - ) - `, - item.ID, item.RoadmapID, item.Title, item.Description, string(item.Category), string(item.Priority), string(item.Status), - item.ControlID, item.RegulationRef, item.GapID, - item.EffortDays, item.EffortHours, item.EstimatedCost, - item.AssigneeID, item.AssigneeName, item.Department, - item.PlannedStart, item.PlannedEnd, item.ActualStart, item.ActualEnd, - dependsOn, blockedBy, - evidenceReq, evidenceProv, - item.Notes, item.RiskNotes, - item.SourceRow, item.SourceFile, item.SortOrder, - item.CreatedAt, item.UpdatedAt, - ) - - return err -} - -// GetItem retrieves a roadmap item by ID -func (s *Store) GetItem(ctx context.Context, id uuid.UUID) (*RoadmapItem, error) { - var item RoadmapItem - var category, priority, status string - var dependsOn, blockedBy, evidenceReq, evidenceProv []byte - - err := s.pool.QueryRow(ctx, ` - SELECT - id, roadmap_id, title, description, category, priority, status, - control_id, regulation_ref, gap_id, - effort_days, effort_hours, estimated_cost, - assignee_id, assignee_name, department, - planned_start, planned_end, actual_start, actual_end, - depends_on, blocked_by, - evidence_required, evidence_provided, - notes, risk_notes, - source_row, source_file, sort_order, - created_at, updated_at - FROM roadmap_items WHERE id = $1 - `, id).Scan( - &item.ID, &item.RoadmapID, &item.Title, &item.Description, &category, &priority, &status, - &item.ControlID, &item.RegulationRef, &item.GapID, - &item.EffortDays, &item.EffortHours, &item.EstimatedCost, - &item.AssigneeID, &item.AssigneeName, &item.Department, - &item.PlannedStart, &item.PlannedEnd, &item.ActualStart, &item.ActualEnd, - &dependsOn, &blockedBy, - &evidenceReq, &evidenceProv, - &item.Notes, &item.RiskNotes, - &item.SourceRow, &item.SourceFile, &item.SortOrder, - &item.CreatedAt, &item.UpdatedAt, - ) - - if err == pgx.ErrNoRows { - return nil, nil - } - if err != nil { - return nil, err - } - - item.Category = ItemCategory(category) - item.Priority = ItemPriority(priority) - item.Status = ItemStatus(status) - json.Unmarshal(dependsOn, &item.DependsOn) - json.Unmarshal(blockedBy, &item.BlockedBy) - json.Unmarshal(evidenceReq, &item.EvidenceRequired) - json.Unmarshal(evidenceProv, &item.EvidenceProvided) - - return &item, nil -} - -// ListItems lists items for a roadmap with optional filters -func (s *Store) ListItems(ctx context.Context, roadmapID uuid.UUID, filters *RoadmapItemFilters) ([]RoadmapItem, error) { - query := ` - SELECT - id, roadmap_id, title, description, category, priority, status, - control_id, regulation_ref, gap_id, - effort_days, effort_hours, estimated_cost, - assignee_id, assignee_name, department, - planned_start, planned_end, actual_start, actual_end, - depends_on, blocked_by, - evidence_required, evidence_provided, - notes, risk_notes, - source_row, source_file, sort_order, - created_at, updated_at - FROM roadmap_items WHERE roadmap_id = $1` - - args := []interface{}{roadmapID} - argIdx := 2 - - if filters != nil { - if filters.Status != "" { - query += fmt.Sprintf(" AND status = $%d", argIdx) - args = append(args, string(filters.Status)) - argIdx++ - } - if filters.Priority != "" { - query += fmt.Sprintf(" AND priority = $%d", argIdx) - args = append(args, string(filters.Priority)) - argIdx++ - } - if filters.Category != "" { - query += fmt.Sprintf(" AND category = $%d", argIdx) - args = append(args, string(filters.Category)) - argIdx++ - } - if filters.AssigneeID != nil { - query += fmt.Sprintf(" AND assignee_id = $%d", argIdx) - args = append(args, *filters.AssigneeID) - argIdx++ - } - if filters.ControlID != "" { - query += fmt.Sprintf(" AND control_id = $%d", argIdx) - args = append(args, filters.ControlID) - argIdx++ - } - if filters.SearchQuery != "" { - query += fmt.Sprintf(" AND (title ILIKE $%d OR description ILIKE $%d)", argIdx, argIdx) - args = append(args, "%"+filters.SearchQuery+"%") - argIdx++ - } - } - - query += " ORDER BY sort_order ASC, priority ASC, created_at ASC" - - 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 items []RoadmapItem - for rows.Next() { - var item RoadmapItem - var category, priority, status string - var dependsOn, blockedBy, evidenceReq, evidenceProv []byte - - err := rows.Scan( - &item.ID, &item.RoadmapID, &item.Title, &item.Description, &category, &priority, &status, - &item.ControlID, &item.RegulationRef, &item.GapID, - &item.EffortDays, &item.EffortHours, &item.EstimatedCost, - &item.AssigneeID, &item.AssigneeName, &item.Department, - &item.PlannedStart, &item.PlannedEnd, &item.ActualStart, &item.ActualEnd, - &dependsOn, &blockedBy, - &evidenceReq, &evidenceProv, - &item.Notes, &item.RiskNotes, - &item.SourceRow, &item.SourceFile, &item.SortOrder, - &item.CreatedAt, &item.UpdatedAt, - ) - if err != nil { - return nil, err - } - - item.Category = ItemCategory(category) - item.Priority = ItemPriority(priority) - item.Status = ItemStatus(status) - json.Unmarshal(dependsOn, &item.DependsOn) - json.Unmarshal(blockedBy, &item.BlockedBy) - json.Unmarshal(evidenceReq, &item.EvidenceRequired) - json.Unmarshal(evidenceProv, &item.EvidenceProvided) - - items = append(items, item) - } - - return items, nil -} - -// UpdateItem updates a roadmap item -func (s *Store) UpdateItem(ctx context.Context, item *RoadmapItem) error { - item.UpdatedAt = time.Now().UTC() - - dependsOn, _ := json.Marshal(item.DependsOn) - blockedBy, _ := json.Marshal(item.BlockedBy) - evidenceReq, _ := json.Marshal(item.EvidenceRequired) - evidenceProv, _ := json.Marshal(item.EvidenceProvided) - - _, err := s.pool.Exec(ctx, ` - UPDATE roadmap_items SET - title = $2, description = $3, category = $4, priority = $5, status = $6, - control_id = $7, regulation_ref = $8, gap_id = $9, - effort_days = $10, effort_hours = $11, estimated_cost = $12, - assignee_id = $13, assignee_name = $14, department = $15, - planned_start = $16, planned_end = $17, actual_start = $18, actual_end = $19, - depends_on = $20, blocked_by = $21, - evidence_required = $22, evidence_provided = $23, - notes = $24, risk_notes = $25, - sort_order = $26, updated_at = $27 - WHERE id = $1 - `, - item.ID, item.Title, item.Description, string(item.Category), string(item.Priority), string(item.Status), - item.ControlID, item.RegulationRef, item.GapID, - item.EffortDays, item.EffortHours, item.EstimatedCost, - item.AssigneeID, item.AssigneeName, item.Department, - item.PlannedStart, item.PlannedEnd, item.ActualStart, item.ActualEnd, - dependsOn, blockedBy, - evidenceReq, evidenceProv, - item.Notes, item.RiskNotes, - item.SortOrder, item.UpdatedAt, - ) - - return err -} - -// DeleteItem deletes a roadmap item -func (s *Store) DeleteItem(ctx context.Context, id uuid.UUID) error { - _, err := s.pool.Exec(ctx, "DELETE FROM roadmap_items WHERE id = $1", id) - return err -} - -// BulkCreateItems creates multiple items in a transaction -func (s *Store) BulkCreateItems(ctx context.Context, items []RoadmapItem) error { - tx, err := s.pool.Begin(ctx) - if err != nil { - return err - } - defer tx.Rollback(ctx) - - for i := range items { - item := &items[i] - item.ID = uuid.New() - item.CreatedAt = time.Now().UTC() - item.UpdatedAt = item.CreatedAt - - dependsOn, _ := json.Marshal(item.DependsOn) - blockedBy, _ := json.Marshal(item.BlockedBy) - evidenceReq, _ := json.Marshal(item.EvidenceRequired) - evidenceProv, _ := json.Marshal(item.EvidenceProvided) - - _, err := tx.Exec(ctx, ` - INSERT INTO roadmap_items ( - id, roadmap_id, title, description, category, priority, status, - control_id, regulation_ref, gap_id, - effort_days, effort_hours, estimated_cost, - assignee_id, assignee_name, department, - planned_start, planned_end, actual_start, actual_end, - depends_on, blocked_by, - evidence_required, evidence_provided, - notes, risk_notes, - source_row, source_file, sort_order, - created_at, updated_at - ) 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, - $25, $26, - $27, $28, $29, - $30, $31 - ) - `, - item.ID, item.RoadmapID, item.Title, item.Description, string(item.Category), string(item.Priority), string(item.Status), - item.ControlID, item.RegulationRef, item.GapID, - item.EffortDays, item.EffortHours, item.EstimatedCost, - item.AssigneeID, item.AssigneeName, item.Department, - item.PlannedStart, item.PlannedEnd, item.ActualStart, item.ActualEnd, - dependsOn, blockedBy, - evidenceReq, evidenceProv, - item.Notes, item.RiskNotes, - item.SourceRow, item.SourceFile, item.SortOrder, - item.CreatedAt, item.UpdatedAt, - ) - if err != nil { - return err - } - } - - return tx.Commit(ctx) -} - -// ============================================================================ -// Import Job Operations -// ============================================================================ - -// CreateImportJob creates a new import job -func (s *Store) CreateImportJob(ctx context.Context, job *ImportJob) error { - job.ID = uuid.New() - job.CreatedAt = time.Now().UTC() - job.UpdatedAt = job.CreatedAt - if job.Status == "" { - job.Status = "pending" - } - - parsedItems, _ := json.Marshal(job.ParsedItems) - - _, err := s.pool.Exec(ctx, ` - INSERT INTO roadmap_import_jobs ( - id, tenant_id, roadmap_id, - filename, format, file_size, content_type, - status, error_message, - total_rows, valid_rows, invalid_rows, imported_items, - parsed_items, - created_at, updated_at, completed_at, created_by - ) VALUES ( - $1, $2, $3, - $4, $5, $6, $7, - $8, $9, - $10, $11, $12, $13, - $14, - $15, $16, $17, $18 - ) - `, - job.ID, job.TenantID, job.RoadmapID, - job.Filename, string(job.Format), job.FileSize, job.ContentType, - job.Status, job.ErrorMessage, - job.TotalRows, job.ValidRows, job.InvalidRows, job.ImportedItems, - parsedItems, - job.CreatedAt, job.UpdatedAt, job.CompletedAt, job.CreatedBy, - ) - - return err -} - -// GetImportJob retrieves an import job by ID -func (s *Store) GetImportJob(ctx context.Context, id uuid.UUID) (*ImportJob, error) { - var job ImportJob - var format string - var parsedItems []byte - - err := s.pool.QueryRow(ctx, ` - SELECT - id, tenant_id, roadmap_id, - filename, format, file_size, content_type, - status, error_message, - total_rows, valid_rows, invalid_rows, imported_items, - parsed_items, - created_at, updated_at, completed_at, created_by - FROM roadmap_import_jobs WHERE id = $1 - `, id).Scan( - &job.ID, &job.TenantID, &job.RoadmapID, - &job.Filename, &format, &job.FileSize, &job.ContentType, - &job.Status, &job.ErrorMessage, - &job.TotalRows, &job.ValidRows, &job.InvalidRows, &job.ImportedItems, - &parsedItems, - &job.CreatedAt, &job.UpdatedAt, &job.CompletedAt, &job.CreatedBy, - ) - - if err == pgx.ErrNoRows { - return nil, nil - } - if err != nil { - return nil, err - } - - job.Format = ImportFormat(format) - json.Unmarshal(parsedItems, &job.ParsedItems) - - return &job, nil -} - -// UpdateImportJob updates an import job -func (s *Store) UpdateImportJob(ctx context.Context, job *ImportJob) error { - job.UpdatedAt = time.Now().UTC() - - parsedItems, _ := json.Marshal(job.ParsedItems) - - _, err := s.pool.Exec(ctx, ` - UPDATE roadmap_import_jobs SET - roadmap_id = $2, - status = $3, error_message = $4, - total_rows = $5, valid_rows = $6, invalid_rows = $7, imported_items = $8, - parsed_items = $9, - updated_at = $10, completed_at = $11 - WHERE id = $1 - `, - job.ID, job.RoadmapID, - job.Status, job.ErrorMessage, - job.TotalRows, job.ValidRows, job.InvalidRows, job.ImportedItems, - parsedItems, - job.UpdatedAt, job.CompletedAt, - ) - - return err -} - -// ============================================================================ -// Statistics -// ============================================================================ - -// GetRoadmapStats returns statistics for a roadmap -func (s *Store) GetRoadmapStats(ctx context.Context, roadmapID uuid.UUID) (*RoadmapStats, error) { - stats := &RoadmapStats{ - ByStatus: make(map[string]int), - ByPriority: make(map[string]int), - ByCategory: make(map[string]int), - ByDepartment: make(map[string]int), - } - - // Total count - s.pool.QueryRow(ctx, - "SELECT COUNT(*) FROM roadmap_items WHERE roadmap_id = $1", - roadmapID).Scan(&stats.TotalItems) - - // By status - rows, err := s.pool.Query(ctx, - "SELECT status, COUNT(*) FROM roadmap_items WHERE roadmap_id = $1 GROUP BY status", - roadmapID) - if err == nil { - defer rows.Close() - for rows.Next() { - var status string - var count int - rows.Scan(&status, &count) - stats.ByStatus[status] = count - } - } - - // By priority - rows, err = s.pool.Query(ctx, - "SELECT priority, COUNT(*) FROM roadmap_items WHERE roadmap_id = $1 GROUP BY priority", - roadmapID) - if err == nil { - defer rows.Close() - for rows.Next() { - var priority string - var count int - rows.Scan(&priority, &count) - stats.ByPriority[priority] = count - } - } - - // By category - rows, err = s.pool.Query(ctx, - "SELECT category, COUNT(*) FROM roadmap_items WHERE roadmap_id = $1 GROUP BY category", - roadmapID) - if err == nil { - defer rows.Close() - for rows.Next() { - var category string - var count int - rows.Scan(&category, &count) - stats.ByCategory[category] = count - } - } - - // By department - rows, err = s.pool.Query(ctx, - "SELECT COALESCE(department, 'Unassigned'), COUNT(*) FROM roadmap_items WHERE roadmap_id = $1 GROUP BY department", - roadmapID) - if err == nil { - defer rows.Close() - for rows.Next() { - var dept string - var count int - rows.Scan(&dept, &count) - stats.ByDepartment[dept] = count - } - } - - // Overdue items - s.pool.QueryRow(ctx, - "SELECT COUNT(*) FROM roadmap_items WHERE roadmap_id = $1 AND planned_end < NOW() AND status NOT IN ('COMPLETED', 'DEFERRED')", - roadmapID).Scan(&stats.OverdueItems) - - // Upcoming items (next 7 days) - s.pool.QueryRow(ctx, - "SELECT COUNT(*) FROM roadmap_items WHERE roadmap_id = $1 AND planned_end BETWEEN NOW() AND NOW() + INTERVAL '7 days' AND status NOT IN ('COMPLETED', 'DEFERRED')", - roadmapID).Scan(&stats.UpcomingItems) - - // Total effort - s.pool.QueryRow(ctx, - "SELECT COALESCE(SUM(effort_days), 0) FROM roadmap_items WHERE roadmap_id = $1", - roadmapID).Scan(&stats.TotalEffortDays) - - // Progress - completedCount := stats.ByStatus[string(ItemStatusCompleted)] - if stats.TotalItems > 0 { - stats.Progress = (completedCount * 100) / stats.TotalItems - } - - return stats, nil -} - -// UpdateRoadmapProgress recalculates and updates roadmap progress -func (s *Store) UpdateRoadmapProgress(ctx context.Context, roadmapID uuid.UUID) error { - var total, completed int - - s.pool.QueryRow(ctx, - "SELECT COUNT(*) FROM roadmap_items WHERE roadmap_id = $1", - roadmapID).Scan(&total) - - s.pool.QueryRow(ctx, - "SELECT COUNT(*) FROM roadmap_items WHERE roadmap_id = $1 AND status = 'COMPLETED'", - roadmapID).Scan(&completed) - - progress := 0 - if total > 0 { - progress = (completed * 100) / total - } - - _, err := s.pool.Exec(ctx, ` - UPDATE roadmaps SET - total_items = $2, - completed_items = $3, - progress = $4, - updated_at = $5 - WHERE id = $1 - `, roadmapID, total, completed, progress, time.Now().UTC()) - - return err -} diff --git a/ai-compliance-sdk/internal/roadmap/store_import.go b/ai-compliance-sdk/internal/roadmap/store_import.go new file mode 100644 index 0000000..5ba3fde --- /dev/null +++ b/ai-compliance-sdk/internal/roadmap/store_import.go @@ -0,0 +1,213 @@ +package roadmap + +import ( + "context" + "encoding/json" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" +) + +// ============================================================================ +// Import Job Operations +// ============================================================================ + +// CreateImportJob creates a new import job +func (s *Store) CreateImportJob(ctx context.Context, job *ImportJob) error { + job.ID = uuid.New() + job.CreatedAt = time.Now().UTC() + job.UpdatedAt = job.CreatedAt + if job.Status == "" { + job.Status = "pending" + } + + parsedItems, _ := json.Marshal(job.ParsedItems) + + _, err := s.pool.Exec(ctx, ` + INSERT INTO roadmap_import_jobs ( + id, tenant_id, roadmap_id, + filename, format, file_size, content_type, + status, error_message, + total_rows, valid_rows, invalid_rows, imported_items, + parsed_items, + created_at, updated_at, completed_at, created_by + ) VALUES ( + $1, $2, $3, + $4, $5, $6, $7, + $8, $9, + $10, $11, $12, $13, + $14, + $15, $16, $17, $18 + ) + `, + job.ID, job.TenantID, job.RoadmapID, + job.Filename, string(job.Format), job.FileSize, job.ContentType, + job.Status, job.ErrorMessage, + job.TotalRows, job.ValidRows, job.InvalidRows, job.ImportedItems, + parsedItems, + job.CreatedAt, job.UpdatedAt, job.CompletedAt, job.CreatedBy, + ) + + return err +} + +// GetImportJob retrieves an import job by ID +func (s *Store) GetImportJob(ctx context.Context, id uuid.UUID) (*ImportJob, error) { + var job ImportJob + var format string + var parsedItems []byte + + err := s.pool.QueryRow(ctx, ` + SELECT + id, tenant_id, roadmap_id, + filename, format, file_size, content_type, + status, error_message, + total_rows, valid_rows, invalid_rows, imported_items, + parsed_items, + created_at, updated_at, completed_at, created_by + FROM roadmap_import_jobs WHERE id = $1 + `, id).Scan( + &job.ID, &job.TenantID, &job.RoadmapID, + &job.Filename, &format, &job.FileSize, &job.ContentType, + &job.Status, &job.ErrorMessage, + &job.TotalRows, &job.ValidRows, &job.InvalidRows, &job.ImportedItems, + &parsedItems, + &job.CreatedAt, &job.UpdatedAt, &job.CompletedAt, &job.CreatedBy, + ) + + if err == pgx.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, err + } + + job.Format = ImportFormat(format) + json.Unmarshal(parsedItems, &job.ParsedItems) + + return &job, nil +} + +// UpdateImportJob updates an import job +func (s *Store) UpdateImportJob(ctx context.Context, job *ImportJob) error { + job.UpdatedAt = time.Now().UTC() + + parsedItems, _ := json.Marshal(job.ParsedItems) + + _, err := s.pool.Exec(ctx, ` + UPDATE roadmap_import_jobs SET + roadmap_id = $2, + status = $3, error_message = $4, + total_rows = $5, valid_rows = $6, invalid_rows = $7, imported_items = $8, + parsed_items = $9, + updated_at = $10, completed_at = $11 + WHERE id = $1 + `, + job.ID, job.RoadmapID, + job.Status, job.ErrorMessage, + job.TotalRows, job.ValidRows, job.InvalidRows, job.ImportedItems, + parsedItems, + job.UpdatedAt, job.CompletedAt, + ) + + return err +} + +// ============================================================================ +// Statistics +// ============================================================================ + +// GetRoadmapStats returns statistics for a roadmap +func (s *Store) GetRoadmapStats(ctx context.Context, roadmapID uuid.UUID) (*RoadmapStats, error) { + stats := &RoadmapStats{ + ByStatus: make(map[string]int), + ByPriority: make(map[string]int), + ByCategory: make(map[string]int), + ByDepartment: make(map[string]int), + } + + // Total count + s.pool.QueryRow(ctx, + "SELECT COUNT(*) FROM roadmap_items WHERE roadmap_id = $1", + roadmapID).Scan(&stats.TotalItems) + + // By status + rows, err := s.pool.Query(ctx, + "SELECT status, COUNT(*) FROM roadmap_items WHERE roadmap_id = $1 GROUP BY status", + roadmapID) + if err == nil { + defer rows.Close() + for rows.Next() { + var status string + var count int + rows.Scan(&status, &count) + stats.ByStatus[status] = count + } + } + + // By priority + rows, err = s.pool.Query(ctx, + "SELECT priority, COUNT(*) FROM roadmap_items WHERE roadmap_id = $1 GROUP BY priority", + roadmapID) + if err == nil { + defer rows.Close() + for rows.Next() { + var priority string + var count int + rows.Scan(&priority, &count) + stats.ByPriority[priority] = count + } + } + + // By category + rows, err = s.pool.Query(ctx, + "SELECT category, COUNT(*) FROM roadmap_items WHERE roadmap_id = $1 GROUP BY category", + roadmapID) + if err == nil { + defer rows.Close() + for rows.Next() { + var category string + var count int + rows.Scan(&category, &count) + stats.ByCategory[category] = count + } + } + + // By department + rows, err = s.pool.Query(ctx, + "SELECT COALESCE(department, 'Unassigned'), COUNT(*) FROM roadmap_items WHERE roadmap_id = $1 GROUP BY department", + roadmapID) + if err == nil { + defer rows.Close() + for rows.Next() { + var dept string + var count int + rows.Scan(&dept, &count) + stats.ByDepartment[dept] = count + } + } + + // Overdue items + s.pool.QueryRow(ctx, + "SELECT COUNT(*) FROM roadmap_items WHERE roadmap_id = $1 AND planned_end < NOW() AND status NOT IN ('COMPLETED', 'DEFERRED')", + roadmapID).Scan(&stats.OverdueItems) + + // Upcoming items (next 7 days) + s.pool.QueryRow(ctx, + "SELECT COUNT(*) FROM roadmap_items WHERE roadmap_id = $1 AND planned_end BETWEEN NOW() AND NOW() + INTERVAL '7 days' AND status NOT IN ('COMPLETED', 'DEFERRED')", + roadmapID).Scan(&stats.UpcomingItems) + + // Total effort + s.pool.QueryRow(ctx, + "SELECT COALESCE(SUM(effort_days), 0) FROM roadmap_items WHERE roadmap_id = $1", + roadmapID).Scan(&stats.TotalEffortDays) + + // Progress + completedCount := stats.ByStatus[string(ItemStatusCompleted)] + if stats.TotalItems > 0 { + stats.Progress = (completedCount * 100) / stats.TotalItems + } + + return stats, nil +} diff --git a/ai-compliance-sdk/internal/roadmap/store_items.go b/ai-compliance-sdk/internal/roadmap/store_items.go new file mode 100644 index 0000000..d6a39c2 --- /dev/null +++ b/ai-compliance-sdk/internal/roadmap/store_items.go @@ -0,0 +1,337 @@ +package roadmap + +import ( + "context" + "encoding/json" + "fmt" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" +) + +// ============================================================================ +// RoadmapItem CRUD Operations +// ============================================================================ + +// CreateItem creates a new roadmap item +func (s *Store) CreateItem(ctx context.Context, item *RoadmapItem) error { + item.ID = uuid.New() + item.CreatedAt = time.Now().UTC() + item.UpdatedAt = item.CreatedAt + if item.Status == "" { + item.Status = ItemStatusPlanned + } + if item.Priority == "" { + item.Priority = ItemPriorityMedium + } + if item.Category == "" { + item.Category = ItemCategoryTechnical + } + + dependsOn, _ := json.Marshal(item.DependsOn) + blockedBy, _ := json.Marshal(item.BlockedBy) + evidenceReq, _ := json.Marshal(item.EvidenceRequired) + evidenceProv, _ := json.Marshal(item.EvidenceProvided) + + _, err := s.pool.Exec(ctx, ` + INSERT INTO roadmap_items ( + id, roadmap_id, title, description, category, priority, status, + control_id, regulation_ref, gap_id, + effort_days, effort_hours, estimated_cost, + assignee_id, assignee_name, department, + planned_start, planned_end, actual_start, actual_end, + depends_on, blocked_by, + evidence_required, evidence_provided, + notes, risk_notes, + source_row, source_file, sort_order, + created_at, updated_at + ) 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, + $25, $26, + $27, $28, $29, + $30, $31 + ) + `, + item.ID, item.RoadmapID, item.Title, item.Description, string(item.Category), string(item.Priority), string(item.Status), + item.ControlID, item.RegulationRef, item.GapID, + item.EffortDays, item.EffortHours, item.EstimatedCost, + item.AssigneeID, item.AssigneeName, item.Department, + item.PlannedStart, item.PlannedEnd, item.ActualStart, item.ActualEnd, + dependsOn, blockedBy, + evidenceReq, evidenceProv, + item.Notes, item.RiskNotes, + item.SourceRow, item.SourceFile, item.SortOrder, + item.CreatedAt, item.UpdatedAt, + ) + + return err +} + +// GetItem retrieves a roadmap item by ID +func (s *Store) GetItem(ctx context.Context, id uuid.UUID) (*RoadmapItem, error) { + var item RoadmapItem + var category, priority, status string + var dependsOn, blockedBy, evidenceReq, evidenceProv []byte + + err := s.pool.QueryRow(ctx, ` + SELECT + id, roadmap_id, title, description, category, priority, status, + control_id, regulation_ref, gap_id, + effort_days, effort_hours, estimated_cost, + assignee_id, assignee_name, department, + planned_start, planned_end, actual_start, actual_end, + depends_on, blocked_by, + evidence_required, evidence_provided, + notes, risk_notes, + source_row, source_file, sort_order, + created_at, updated_at + FROM roadmap_items WHERE id = $1 + `, id).Scan( + &item.ID, &item.RoadmapID, &item.Title, &item.Description, &category, &priority, &status, + &item.ControlID, &item.RegulationRef, &item.GapID, + &item.EffortDays, &item.EffortHours, &item.EstimatedCost, + &item.AssigneeID, &item.AssigneeName, &item.Department, + &item.PlannedStart, &item.PlannedEnd, &item.ActualStart, &item.ActualEnd, + &dependsOn, &blockedBy, + &evidenceReq, &evidenceProv, + &item.Notes, &item.RiskNotes, + &item.SourceRow, &item.SourceFile, &item.SortOrder, + &item.CreatedAt, &item.UpdatedAt, + ) + + if err == pgx.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, err + } + + item.Category = ItemCategory(category) + item.Priority = ItemPriority(priority) + item.Status = ItemStatus(status) + json.Unmarshal(dependsOn, &item.DependsOn) + json.Unmarshal(blockedBy, &item.BlockedBy) + json.Unmarshal(evidenceReq, &item.EvidenceRequired) + json.Unmarshal(evidenceProv, &item.EvidenceProvided) + + return &item, nil +} + +// ListItems lists items for a roadmap with optional filters +func (s *Store) ListItems(ctx context.Context, roadmapID uuid.UUID, filters *RoadmapItemFilters) ([]RoadmapItem, error) { + query := ` + SELECT + id, roadmap_id, title, description, category, priority, status, + control_id, regulation_ref, gap_id, + effort_days, effort_hours, estimated_cost, + assignee_id, assignee_name, department, + planned_start, planned_end, actual_start, actual_end, + depends_on, blocked_by, + evidence_required, evidence_provided, + notes, risk_notes, + source_row, source_file, sort_order, + created_at, updated_at + FROM roadmap_items WHERE roadmap_id = $1` + + args := []interface{}{roadmapID} + argIdx := 2 + + if filters != nil { + if filters.Status != "" { + query += fmt.Sprintf(" AND status = $%d", argIdx) + args = append(args, string(filters.Status)) + argIdx++ + } + if filters.Priority != "" { + query += fmt.Sprintf(" AND priority = $%d", argIdx) + args = append(args, string(filters.Priority)) + argIdx++ + } + if filters.Category != "" { + query += fmt.Sprintf(" AND category = $%d", argIdx) + args = append(args, string(filters.Category)) + argIdx++ + } + if filters.AssigneeID != nil { + query += fmt.Sprintf(" AND assignee_id = $%d", argIdx) + args = append(args, *filters.AssigneeID) + argIdx++ + } + if filters.ControlID != "" { + query += fmt.Sprintf(" AND control_id = $%d", argIdx) + args = append(args, filters.ControlID) + argIdx++ + } + if filters.SearchQuery != "" { + query += fmt.Sprintf(" AND (title ILIKE $%d OR description ILIKE $%d)", argIdx, argIdx) + args = append(args, "%"+filters.SearchQuery+"%") + argIdx++ + } + } + + query += " ORDER BY sort_order ASC, priority ASC, created_at ASC" + + 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 items []RoadmapItem + for rows.Next() { + var item RoadmapItem + var category, priority, status string + var dependsOn, blockedBy, evidenceReq, evidenceProv []byte + + err := rows.Scan( + &item.ID, &item.RoadmapID, &item.Title, &item.Description, &category, &priority, &status, + &item.ControlID, &item.RegulationRef, &item.GapID, + &item.EffortDays, &item.EffortHours, &item.EstimatedCost, + &item.AssigneeID, &item.AssigneeName, &item.Department, + &item.PlannedStart, &item.PlannedEnd, &item.ActualStart, &item.ActualEnd, + &dependsOn, &blockedBy, + &evidenceReq, &evidenceProv, + &item.Notes, &item.RiskNotes, + &item.SourceRow, &item.SourceFile, &item.SortOrder, + &item.CreatedAt, &item.UpdatedAt, + ) + if err != nil { + return nil, err + } + + item.Category = ItemCategory(category) + item.Priority = ItemPriority(priority) + item.Status = ItemStatus(status) + json.Unmarshal(dependsOn, &item.DependsOn) + json.Unmarshal(blockedBy, &item.BlockedBy) + json.Unmarshal(evidenceReq, &item.EvidenceRequired) + json.Unmarshal(evidenceProv, &item.EvidenceProvided) + + items = append(items, item) + } + + return items, nil +} + +// UpdateItem updates a roadmap item +func (s *Store) UpdateItem(ctx context.Context, item *RoadmapItem) error { + item.UpdatedAt = time.Now().UTC() + + dependsOn, _ := json.Marshal(item.DependsOn) + blockedBy, _ := json.Marshal(item.BlockedBy) + evidenceReq, _ := json.Marshal(item.EvidenceRequired) + evidenceProv, _ := json.Marshal(item.EvidenceProvided) + + _, err := s.pool.Exec(ctx, ` + UPDATE roadmap_items SET + title = $2, description = $3, category = $4, priority = $5, status = $6, + control_id = $7, regulation_ref = $8, gap_id = $9, + effort_days = $10, effort_hours = $11, estimated_cost = $12, + assignee_id = $13, assignee_name = $14, department = $15, + planned_start = $16, planned_end = $17, actual_start = $18, actual_end = $19, + depends_on = $20, blocked_by = $21, + evidence_required = $22, evidence_provided = $23, + notes = $24, risk_notes = $25, + sort_order = $26, updated_at = $27 + WHERE id = $1 + `, + item.ID, item.Title, item.Description, string(item.Category), string(item.Priority), string(item.Status), + item.ControlID, item.RegulationRef, item.GapID, + item.EffortDays, item.EffortHours, item.EstimatedCost, + item.AssigneeID, item.AssigneeName, item.Department, + item.PlannedStart, item.PlannedEnd, item.ActualStart, item.ActualEnd, + dependsOn, blockedBy, + evidenceReq, evidenceProv, + item.Notes, item.RiskNotes, + item.SortOrder, item.UpdatedAt, + ) + + return err +} + +// DeleteItem deletes a roadmap item +func (s *Store) DeleteItem(ctx context.Context, id uuid.UUID) error { + _, err := s.pool.Exec(ctx, "DELETE FROM roadmap_items WHERE id = $1", id) + return err +} + +// BulkCreateItems creates multiple items in a transaction +func (s *Store) BulkCreateItems(ctx context.Context, items []RoadmapItem) error { + tx, err := s.pool.Begin(ctx) + if err != nil { + return err + } + defer tx.Rollback(ctx) + + for i := range items { + item := &items[i] + item.ID = uuid.New() + item.CreatedAt = time.Now().UTC() + item.UpdatedAt = item.CreatedAt + + dependsOn, _ := json.Marshal(item.DependsOn) + blockedBy, _ := json.Marshal(item.BlockedBy) + evidenceReq, _ := json.Marshal(item.EvidenceRequired) + evidenceProv, _ := json.Marshal(item.EvidenceProvided) + + _, err := tx.Exec(ctx, ` + INSERT INTO roadmap_items ( + id, roadmap_id, title, description, category, priority, status, + control_id, regulation_ref, gap_id, + effort_days, effort_hours, estimated_cost, + assignee_id, assignee_name, department, + planned_start, planned_end, actual_start, actual_end, + depends_on, blocked_by, + evidence_required, evidence_provided, + notes, risk_notes, + source_row, source_file, sort_order, + created_at, updated_at + ) 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, + $25, $26, + $27, $28, $29, + $30, $31 + ) + `, + item.ID, item.RoadmapID, item.Title, item.Description, string(item.Category), string(item.Priority), string(item.Status), + item.ControlID, item.RegulationRef, item.GapID, + item.EffortDays, item.EffortHours, item.EstimatedCost, + item.AssigneeID, item.AssigneeName, item.Department, + item.PlannedStart, item.PlannedEnd, item.ActualStart, item.ActualEnd, + dependsOn, blockedBy, + evidenceReq, evidenceProv, + item.Notes, item.RiskNotes, + item.SourceRow, item.SourceFile, item.SortOrder, + item.CreatedAt, item.UpdatedAt, + ) + if err != nil { + return err + } + } + + return tx.Commit(ctx) +} diff --git a/ai-compliance-sdk/internal/roadmap/store_roadmap.go b/ai-compliance-sdk/internal/roadmap/store_roadmap.go new file mode 100644 index 0000000..8a18b01 --- /dev/null +++ b/ai-compliance-sdk/internal/roadmap/store_roadmap.go @@ -0,0 +1,227 @@ +package roadmap + +import ( + "context" + "fmt" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" +) + +// Store handles roadmap data persistence +type Store struct { + pool *pgxpool.Pool +} + +// NewStore creates a new roadmap store +func NewStore(pool *pgxpool.Pool) *Store { + return &Store{pool: pool} +} + +// ============================================================================ +// Roadmap CRUD Operations +// ============================================================================ + +// CreateRoadmap creates a new roadmap +func (s *Store) CreateRoadmap(ctx context.Context, r *Roadmap) error { + r.ID = uuid.New() + r.CreatedAt = time.Now().UTC() + r.UpdatedAt = r.CreatedAt + if r.Status == "" { + r.Status = "draft" + } + if r.Version == "" { + r.Version = "1.0" + } + + _, err := s.pool.Exec(ctx, ` + INSERT INTO roadmaps ( + id, tenant_id, namespace_id, title, description, version, + assessment_id, portfolio_id, status, + total_items, completed_items, progress, + start_date, target_date, + created_at, updated_at, created_by + ) VALUES ( + $1, $2, $3, $4, $5, $6, + $7, $8, $9, + $10, $11, $12, + $13, $14, + $15, $16, $17 + ) + `, + r.ID, r.TenantID, r.NamespaceID, r.Title, r.Description, r.Version, + r.AssessmentID, r.PortfolioID, r.Status, + r.TotalItems, r.CompletedItems, r.Progress, + r.StartDate, r.TargetDate, + r.CreatedAt, r.UpdatedAt, r.CreatedBy, + ) + + return err +} + +// GetRoadmap retrieves a roadmap by ID +func (s *Store) GetRoadmap(ctx context.Context, id uuid.UUID) (*Roadmap, error) { + var r Roadmap + + err := s.pool.QueryRow(ctx, ` + SELECT + id, tenant_id, namespace_id, title, description, version, + assessment_id, portfolio_id, status, + total_items, completed_items, progress, + start_date, target_date, + created_at, updated_at, created_by + FROM roadmaps WHERE id = $1 + `, id).Scan( + &r.ID, &r.TenantID, &r.NamespaceID, &r.Title, &r.Description, &r.Version, + &r.AssessmentID, &r.PortfolioID, &r.Status, + &r.TotalItems, &r.CompletedItems, &r.Progress, + &r.StartDate, &r.TargetDate, + &r.CreatedAt, &r.UpdatedAt, &r.CreatedBy, + ) + + if err == pgx.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, err + } + + return &r, nil +} + +// ListRoadmaps lists roadmaps for a tenant with optional filters +func (s *Store) ListRoadmaps(ctx context.Context, tenantID uuid.UUID, filters *RoadmapFilters) ([]Roadmap, error) { + query := ` + SELECT + id, tenant_id, namespace_id, title, description, version, + assessment_id, portfolio_id, status, + total_items, completed_items, progress, + start_date, target_date, + created_at, updated_at, created_by + FROM roadmaps 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, filters.Status) + argIdx++ + } + if filters.AssessmentID != nil { + query += fmt.Sprintf(" AND assessment_id = $%d", argIdx) + args = append(args, *filters.AssessmentID) + argIdx++ + } + if filters.PortfolioID != nil { + query += fmt.Sprintf(" AND portfolio_id = $%d", argIdx) + args = append(args, *filters.PortfolioID) + argIdx++ + } + } + + query += " ORDER BY created_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 roadmaps []Roadmap + for rows.Next() { + var r Roadmap + err := rows.Scan( + &r.ID, &r.TenantID, &r.NamespaceID, &r.Title, &r.Description, &r.Version, + &r.AssessmentID, &r.PortfolioID, &r.Status, + &r.TotalItems, &r.CompletedItems, &r.Progress, + &r.StartDate, &r.TargetDate, + &r.CreatedAt, &r.UpdatedAt, &r.CreatedBy, + ) + if err != nil { + return nil, err + } + roadmaps = append(roadmaps, r) + } + + return roadmaps, nil +} + +// UpdateRoadmap updates a roadmap +func (s *Store) UpdateRoadmap(ctx context.Context, r *Roadmap) error { + r.UpdatedAt = time.Now().UTC() + + _, err := s.pool.Exec(ctx, ` + UPDATE roadmaps SET + title = $2, description = $3, version = $4, + assessment_id = $5, portfolio_id = $6, status = $7, + total_items = $8, completed_items = $9, progress = $10, + start_date = $11, target_date = $12, + updated_at = $13 + WHERE id = $1 + `, + r.ID, r.Title, r.Description, r.Version, + r.AssessmentID, r.PortfolioID, r.Status, + r.TotalItems, r.CompletedItems, r.Progress, + r.StartDate, r.TargetDate, + r.UpdatedAt, + ) + + return err +} + +// DeleteRoadmap deletes a roadmap and its items +func (s *Store) DeleteRoadmap(ctx context.Context, id uuid.UUID) error { + // Delete items first + _, err := s.pool.Exec(ctx, "DELETE FROM roadmap_items WHERE roadmap_id = $1", id) + if err != nil { + return err + } + + // Delete roadmap + _, err = s.pool.Exec(ctx, "DELETE FROM roadmaps WHERE id = $1", id) + return err +} + +// UpdateRoadmapProgress recalculates and updates roadmap progress +func (s *Store) UpdateRoadmapProgress(ctx context.Context, roadmapID uuid.UUID) error { + var total, completed int + + s.pool.QueryRow(ctx, + "SELECT COUNT(*) FROM roadmap_items WHERE roadmap_id = $1", + roadmapID).Scan(&total) + + s.pool.QueryRow(ctx, + "SELECT COUNT(*) FROM roadmap_items WHERE roadmap_id = $1 AND status = 'COMPLETED'", + roadmapID).Scan(&completed) + + progress := 0 + if total > 0 { + progress = (completed * 100) / total + } + + _, err := s.pool.Exec(ctx, ` + UPDATE roadmaps SET + total_items = $2, + completed_items = $3, + progress = $4, + updated_at = $5 + WHERE id = $1 + `, roadmapID, total, completed, progress, time.Now().UTC()) + + return err +} diff --git a/ai-compliance-sdk/internal/training/models.go b/ai-compliance-sdk/internal/training/models.go deleted file mode 100644 index 6a5830b..0000000 --- a/ai-compliance-sdk/internal/training/models.go +++ /dev/null @@ -1,757 +0,0 @@ -package training - -import ( - "time" - - "github.com/google/uuid" -) - -// ============================================================================ -// Constants / Enums -// ============================================================================ - -// RegulationArea represents a compliance regulation area -type RegulationArea string - -const ( - RegulationDSGVO RegulationArea = "dsgvo" - RegulationNIS2 RegulationArea = "nis2" - RegulationISO27001 RegulationArea = "iso27001" - RegulationAIAct RegulationArea = "ai_act" - RegulationGeschGehG RegulationArea = "geschgehg" - RegulationHinSchG RegulationArea = "hinschg" -) - -// FrequencyType represents the training frequency -type FrequencyType string - -const ( - FrequencyOnboarding FrequencyType = "onboarding" - FrequencyAnnual FrequencyType = "annual" - FrequencyEventTrigger FrequencyType = "event_trigger" - FrequencyMicro FrequencyType = "micro" -) - -// AssignmentStatus represents the status of a training assignment -type AssignmentStatus string - -const ( - AssignmentStatusPending AssignmentStatus = "pending" - AssignmentStatusInProgress AssignmentStatus = "in_progress" - AssignmentStatusCompleted AssignmentStatus = "completed" - AssignmentStatusOverdue AssignmentStatus = "overdue" - AssignmentStatusExpired AssignmentStatus = "expired" -) - -// TriggerType represents how a training was assigned -type TriggerType string - -const ( - TriggerOnboarding TriggerType = "onboarding" - TriggerAnnual TriggerType = "annual" - TriggerEvent TriggerType = "event" - TriggerManual TriggerType = "manual" -) - -// ContentFormat represents the format of module content -type ContentFormat string - -const ( - ContentFormatMarkdown ContentFormat = "markdown" - ContentFormatHTML ContentFormat = "html" -) - -// Difficulty represents the difficulty level of a quiz question -type Difficulty string - -const ( - DifficultyEasy Difficulty = "easy" - DifficultyMedium Difficulty = "medium" - DifficultyHard Difficulty = "hard" -) - -// AuditAction represents an action in the audit trail -type AuditAction string - -const ( - AuditActionAssigned AuditAction = "assigned" - AuditActionStarted AuditAction = "started" - AuditActionCompleted AuditAction = "completed" - AuditActionQuizSubmitted AuditAction = "quiz_submitted" - AuditActionEscalated AuditAction = "escalated" - AuditActionCertificateIssued AuditAction = "certificate_issued" - AuditActionContentGenerated AuditAction = "content_generated" -) - -// AuditEntityType represents the type of entity in audit log -type AuditEntityType string - -const ( - AuditEntityAssignment AuditEntityType = "assignment" - AuditEntityModule AuditEntityType = "module" - AuditEntityQuiz AuditEntityType = "quiz" - AuditEntityCertificate AuditEntityType = "certificate" -) - -// ============================================================================ -// Role Constants -// ============================================================================ - -const ( - RoleR1 = "R1" // Geschaeftsfuehrung - RoleR2 = "R2" // IT-Leitung - RoleR3 = "R3" // DSB - RoleR4 = "R4" // ISB - RoleR5 = "R5" // HR - RoleR6 = "R6" // Einkauf - RoleR7 = "R7" // Fachabteilung - RoleR8 = "R8" // IT-Admin - RoleR9 = "R9" // Alle Mitarbeiter - RoleR10 = "R10" // Behoerden / Oeffentlicher Dienst -) - -// RoleLabels maps role codes to human-readable labels -var RoleLabels = map[string]string{ - RoleR1: "Geschaeftsfuehrung", - RoleR2: "IT-Leitung", - RoleR3: "Datenschutzbeauftragter", - RoleR4: "Informationssicherheitsbeauftragter", - RoleR5: "HR / Personal", - RoleR6: "Einkauf / Beschaffung", - RoleR7: "Fachabteilung", - RoleR8: "IT-Administration", - RoleR9: "Alle Mitarbeiter", - RoleR10: "Behoerden / Oeffentlicher Dienst", -} - -// NIS2RoleMapping maps internal roles to NIS2 levels -var NIS2RoleMapping = map[string]string{ - RoleR1: "N1", // Geschaeftsfuehrung - RoleR2: "N2", // IT-Leitung - RoleR3: "N3", // DSB - RoleR4: "N3", // ISB - RoleR5: "N4", // HR - RoleR6: "N4", // Einkauf - RoleR7: "N5", // Fachabteilung - RoleR8: "N2", // IT-Admin - RoleR9: "N5", // Alle Mitarbeiter - RoleR10: "N4", // Behoerden -} - -// TargetAudienceRoleMapping maps canonical control target_audience values to CTM roles -var TargetAudienceRoleMapping = map[string][]string{ - "enterprise": {RoleR1, RoleR4, RoleR5, RoleR6, RoleR7, RoleR9}, // Unternehmen - "authority": {RoleR10}, // Behoerden - "provider": {RoleR2, RoleR8}, // IT-Dienstleister - "all": {RoleR1, RoleR2, RoleR3, RoleR4, RoleR5, RoleR6, RoleR7, RoleR8, RoleR9, RoleR10}, -} - -// CategoryRoleMapping provides additional role hints based on control category -var CategoryRoleMapping = map[string][]string{ - "encryption": {RoleR2, RoleR8}, - "authentication": {RoleR2, RoleR8, RoleR9}, - "network": {RoleR2, RoleR8}, - "data_protection": {RoleR3, RoleR5, RoleR9}, - "logging": {RoleR2, RoleR4, RoleR8}, - "incident": {RoleR1, RoleR4}, - "continuity": {RoleR1, RoleR2, RoleR4}, - "compliance": {RoleR1, RoleR3, RoleR4}, - "supply_chain": {RoleR6}, - "physical": {RoleR7}, - "personnel": {RoleR5, RoleR9}, - "application": {RoleR8}, - "system": {RoleR2, RoleR8}, - "risk": {RoleR1, RoleR4}, - "governance": {RoleR1, RoleR4}, - "hardware": {RoleR2, RoleR8}, - "identity": {RoleR2, RoleR3, RoleR8}, -} - -// ============================================================================ -// Main Entities -// ============================================================================ - -// TrainingModule represents a compliance training module -type TrainingModule struct { - ID uuid.UUID `json:"id"` - TenantID uuid.UUID `json:"tenant_id"` - AcademyCourseID *uuid.UUID `json:"academy_course_id,omitempty"` - ModuleCode string `json:"module_code"` - Title string `json:"title"` - Description string `json:"description,omitempty"` - RegulationArea RegulationArea `json:"regulation_area"` - NIS2Relevant bool `json:"nis2_relevant"` - ISOControls []string `json:"iso_controls"` // JSONB - FrequencyType FrequencyType `json:"frequency_type"` - ValidityDays int `json:"validity_days"` - RiskWeight float64 `json:"risk_weight"` - ContentType string `json:"content_type"` - DurationMinutes int `json:"duration_minutes"` - PassThreshold int `json:"pass_threshold"` - IsActive bool `json:"is_active"` - SortOrder int `json:"sort_order"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` -} - -// TrainingMatrixEntry represents a role-to-module mapping in the CTM -type TrainingMatrixEntry struct { - ID uuid.UUID `json:"id"` - TenantID uuid.UUID `json:"tenant_id"` - RoleCode string `json:"role_code"` - ModuleID uuid.UUID `json:"module_id"` - IsMandatory bool `json:"is_mandatory"` - Priority int `json:"priority"` - CreatedAt time.Time `json:"created_at"` - // Joined fields (optional, populated in queries) - ModuleCode string `json:"module_code,omitempty"` - ModuleTitle string `json:"module_title,omitempty"` -} - -// TrainingAssignment represents a user's training assignment -type TrainingAssignment struct { - ID uuid.UUID `json:"id"` - TenantID uuid.UUID `json:"tenant_id"` - ModuleID uuid.UUID `json:"module_id"` - UserID uuid.UUID `json:"user_id"` - UserName string `json:"user_name"` - UserEmail string `json:"user_email"` - RoleCode string `json:"role_code,omitempty"` - TriggerType TriggerType `json:"trigger_type"` - TriggerEvent string `json:"trigger_event,omitempty"` - Status AssignmentStatus `json:"status"` - ProgressPercent int `json:"progress_percent"` - QuizScore *float64 `json:"quiz_score,omitempty"` - QuizPassed *bool `json:"quiz_passed,omitempty"` - QuizAttempts int `json:"quiz_attempts"` - StartedAt *time.Time `json:"started_at,omitempty"` - CompletedAt *time.Time `json:"completed_at,omitempty"` - Deadline time.Time `json:"deadline"` - CertificateID *uuid.UUID `json:"certificate_id,omitempty"` - EscalationLevel int `json:"escalation_level"` - LastEscalationAt *time.Time `json:"last_escalation_at,omitempty"` - EnrollmentID *uuid.UUID `json:"enrollment_id,omitempty"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - // Joined fields - ModuleCode string `json:"module_code,omitempty"` - ModuleTitle string `json:"module_title,omitempty"` -} - -// QuizQuestion represents a persistent quiz question for a module -type QuizQuestion struct { - ID uuid.UUID `json:"id"` - ModuleID uuid.UUID `json:"module_id"` - Question string `json:"question"` - Options []string `json:"options"` // JSONB - CorrectIndex int `json:"correct_index"` - Explanation string `json:"explanation,omitempty"` - Difficulty Difficulty `json:"difficulty"` - IsActive bool `json:"is_active"` - SortOrder int `json:"sort_order"` - CreatedAt time.Time `json:"created_at"` -} - -// QuizAttempt represents a single quiz attempt by a user -type QuizAttempt struct { - ID uuid.UUID `json:"id"` - AssignmentID uuid.UUID `json:"assignment_id"` - UserID uuid.UUID `json:"user_id"` - Answers []QuizAnswer `json:"answers"` // JSONB - Score float64 `json:"score"` - Passed bool `json:"passed"` - CorrectCount int `json:"correct_count"` - TotalCount int `json:"total_count"` - DurationSeconds *int `json:"duration_seconds,omitempty"` - AttemptedAt time.Time `json:"attempted_at"` -} - -// QuizAnswer represents a single answer within a quiz attempt -type QuizAnswer struct { - QuestionID uuid.UUID `json:"question_id"` - SelectedIndex int `json:"selected_index"` - Correct bool `json:"correct"` -} - -// AuditLogEntry represents an entry in the training audit trail -type AuditLogEntry struct { - ID uuid.UUID `json:"id"` - TenantID uuid.UUID `json:"tenant_id"` - UserID *uuid.UUID `json:"user_id,omitempty"` - Action AuditAction `json:"action"` - EntityType AuditEntityType `json:"entity_type"` - EntityID *uuid.UUID `json:"entity_id,omitempty"` - Details map[string]interface{} `json:"details"` // JSONB - IPAddress string `json:"ip_address,omitempty"` - CreatedAt time.Time `json:"created_at"` -} - -// ModuleContent represents LLM-generated or manual content for a module -type ModuleContent struct { - ID uuid.UUID `json:"id"` - ModuleID uuid.UUID `json:"module_id"` - Version int `json:"version"` - ContentFormat ContentFormat `json:"content_format"` - ContentBody string `json:"content_body"` - Summary string `json:"summary,omitempty"` - GeneratedBy string `json:"generated_by,omitempty"` - LLMModel string `json:"llm_model,omitempty"` - IsPublished bool `json:"is_published"` - ReviewedBy *uuid.UUID `json:"reviewed_by,omitempty"` - ReviewedAt *time.Time `json:"reviewed_at,omitempty"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` -} - -// TrainingStats contains aggregated training metrics -type TrainingStats struct { - TotalModules int `json:"total_modules"` - TotalAssignments int `json:"total_assignments"` - CompletionRate float64 `json:"completion_rate"` - OverdueCount int `json:"overdue_count"` - PendingCount int `json:"pending_count"` - InProgressCount int `json:"in_progress_count"` - CompletedCount int `json:"completed_count"` - AvgQuizScore float64 `json:"avg_quiz_score"` - AvgCompletionDays float64 `json:"avg_completion_days"` - UpcomingDeadlines int `json:"upcoming_deadlines"` // within 7 days -} - -// ComplianceGap represents a missing or overdue training requirement -type ComplianceGap struct { - ModuleID uuid.UUID `json:"module_id"` - ModuleCode string `json:"module_code"` - ModuleTitle string `json:"module_title"` - RegulationArea RegulationArea `json:"regulation_area"` - RoleCode string `json:"role_code"` - IsMandatory bool `json:"is_mandatory"` - AssignmentID *uuid.UUID `json:"assignment_id,omitempty"` - Status string `json:"status"` // "missing", "overdue", "expired" - Deadline *time.Time `json:"deadline,omitempty"` -} - -// EscalationResult represents the result of an escalation check -type EscalationResult struct { - AssignmentID uuid.UUID `json:"assignment_id"` - UserID uuid.UUID `json:"user_id"` - UserName string `json:"user_name"` - UserEmail string `json:"user_email"` - ModuleTitle string `json:"module_title"` - PreviousLevel int `json:"previous_level"` - NewLevel int `json:"new_level"` - DaysOverdue int `json:"days_overdue"` - EscalationLabel string `json:"escalation_label"` -} - -// DeadlineInfo represents upcoming deadline information -type DeadlineInfo struct { - AssignmentID uuid.UUID `json:"assignment_id"` - ModuleCode string `json:"module_code"` - ModuleTitle string `json:"module_title"` - UserID uuid.UUID `json:"user_id"` - UserName string `json:"user_name"` - Deadline time.Time `json:"deadline"` - DaysLeft int `json:"days_left"` - Status AssignmentStatus `json:"status"` -} - -// ============================================================================ -// Filter Types -// ============================================================================ - -// ModuleFilters defines filters for listing modules -type ModuleFilters struct { - RegulationArea RegulationArea - FrequencyType FrequencyType - IsActive *bool - NIS2Relevant *bool - Search string - Limit int - Offset int -} - -// AssignmentFilters defines filters for listing assignments -type AssignmentFilters struct { - ModuleID *uuid.UUID - UserID *uuid.UUID - RoleCode string - Status AssignmentStatus - Overdue *bool - Limit int - Offset int -} - -// AuditLogFilters defines filters for listing audit log entries -type AuditLogFilters struct { - UserID *uuid.UUID - Action AuditAction - EntityType AuditEntityType - Limit int - Offset int -} - -// ============================================================================ -// API Request/Response Types -// ============================================================================ - -// CreateModuleRequest is the API request for creating a training module -type CreateModuleRequest struct { - ModuleCode string `json:"module_code" binding:"required"` - Title string `json:"title" binding:"required"` - Description string `json:"description,omitempty"` - RegulationArea RegulationArea `json:"regulation_area" binding:"required"` - NIS2Relevant bool `json:"nis2_relevant"` - ISOControls []string `json:"iso_controls,omitempty"` - FrequencyType FrequencyType `json:"frequency_type" binding:"required"` - ValidityDays int `json:"validity_days"` - RiskWeight float64 `json:"risk_weight"` - ContentType string `json:"content_type"` - DurationMinutes int `json:"duration_minutes"` - PassThreshold int `json:"pass_threshold"` -} - -// UpdateModuleRequest is the API request for updating a training module -type UpdateModuleRequest struct { - Title *string `json:"title,omitempty"` - Description *string `json:"description,omitempty"` - NIS2Relevant *bool `json:"nis2_relevant,omitempty"` - ISOControls []string `json:"iso_controls,omitempty"` - ValidityDays *int `json:"validity_days,omitempty"` - RiskWeight *float64 `json:"risk_weight,omitempty"` - DurationMinutes *int `json:"duration_minutes,omitempty"` - PassThreshold *int `json:"pass_threshold,omitempty"` - IsActive *bool `json:"is_active,omitempty"` -} - -// SetMatrixEntryRequest is the API request for setting a CTM entry -type SetMatrixEntryRequest struct { - RoleCode string `json:"role_code" binding:"required"` - ModuleID uuid.UUID `json:"module_id" binding:"required"` - IsMandatory bool `json:"is_mandatory"` - Priority int `json:"priority"` -} - -// ComputeAssignmentsRequest is the API request for computing assignments -type ComputeAssignmentsRequest struct { - UserID uuid.UUID `json:"user_id" binding:"required"` - UserName string `json:"user_name" binding:"required"` - UserEmail string `json:"user_email" binding:"required"` - Roles []string `json:"roles" binding:"required"` - Trigger string `json:"trigger"` -} - -// UpdateAssignmentProgressRequest updates progress on an assignment -type UpdateAssignmentProgressRequest struct { - Progress int `json:"progress" binding:"required"` -} - -// SubmitTrainingQuizRequest is the API request for submitting a quiz -type SubmitTrainingQuizRequest struct { - AssignmentID uuid.UUID `json:"assignment_id" binding:"required"` - Answers []QuizAnswer `json:"answers" binding:"required"` - DurationSeconds *int `json:"duration_seconds,omitempty"` -} - -// SubmitTrainingQuizResponse is the API response for quiz submission -type SubmitTrainingQuizResponse struct { - AttemptID uuid.UUID `json:"attempt_id"` - Score float64 `json:"score"` - Passed bool `json:"passed"` - CorrectCount int `json:"correct_count"` - TotalCount int `json:"total_count"` - Threshold int `json:"threshold"` -} - -// GenerateContentRequest is the API request for LLM content generation -type GenerateContentRequest struct { - ModuleID uuid.UUID `json:"module_id" binding:"required"` - Language string `json:"language"` -} - -// GenerateQuizRequest is the API request for LLM quiz generation -type GenerateQuizRequest struct { - ModuleID uuid.UUID `json:"module_id" binding:"required"` - Count int `json:"count"` -} - -// PublishContentRequest is the API request for publishing content -type PublishContentRequest struct { - ReviewedBy uuid.UUID `json:"reviewed_by"` -} - -// BulkAssignRequest is the API request for bulk assigning a module -type BulkAssignRequest struct { - ModuleID uuid.UUID `json:"module_id" binding:"required"` - RoleCodes []string `json:"role_codes" binding:"required"` - Trigger string `json:"trigger"` - Deadline time.Time `json:"deadline" binding:"required"` -} - -// ModuleListResponse is the API response for listing modules -type ModuleListResponse struct { - Modules []TrainingModule `json:"modules"` - Total int `json:"total"` -} - -// AssignmentListResponse is the API response for listing assignments -type AssignmentListResponse struct { - Assignments []TrainingAssignment `json:"assignments"` - Total int `json:"total"` -} - -// MatrixResponse is the API response for the full training matrix -type MatrixResponse struct { - Entries map[string][]TrainingMatrixEntry `json:"entries"` // role_code -> entries - Roles map[string]string `json:"roles"` // role_code -> label -} - -// AuditLogResponse is the API response for listing audit log entries -type AuditLogResponse struct { - Entries []AuditLogEntry `json:"entries"` - Total int `json:"total"` -} - -// EscalationResponse is the API response for escalation check -type EscalationResponse struct { - Results []EscalationResult `json:"results"` - TotalChecked int `json:"total_checked"` - Escalated int `json:"escalated"` -} - -// DeadlineListResponse is the API response for listing deadlines -type DeadlineListResponse struct { - Deadlines []DeadlineInfo `json:"deadlines"` - Total int `json:"total"` -} - -// BulkResult holds the result of a bulk generation operation -type BulkResult struct { - Generated int `json:"generated"` - Skipped int `json:"skipped"` - Errors []string `json:"errors"` -} - -// ============================================================================ -// Training Block Types (Controls → Schulungsmodule Pipeline) -// ============================================================================ - -// TrainingBlockConfig defines how canonical controls are grouped into training modules -type TrainingBlockConfig struct { - ID uuid.UUID `json:"id"` - TenantID uuid.UUID `json:"tenant_id"` - Name string `json:"name"` - Description string `json:"description,omitempty"` - DomainFilter string `json:"domain_filter,omitempty"` // "AUTH", "CRYP", etc. - CategoryFilter string `json:"category_filter,omitempty"` // "authentication", etc. - SeverityFilter string `json:"severity_filter,omitempty"` // "high", "critical" - TargetAudienceFilter string `json:"target_audience_filter,omitempty"` // "enterprise", "authority", "provider", "all" - RegulationArea RegulationArea `json:"regulation_area"` - ModuleCodePrefix string `json:"module_code_prefix"` - FrequencyType FrequencyType `json:"frequency_type"` - DurationMinutes int `json:"duration_minutes"` - PassThreshold int `json:"pass_threshold"` - MaxControlsPerModule int `json:"max_controls_per_module"` - IsActive bool `json:"is_active"` - LastGeneratedAt *time.Time `json:"last_generated_at,omitempty"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` -} - -// TrainingBlockControlLink tracks which canonical controls are linked to which module -type TrainingBlockControlLink struct { - ID uuid.UUID `json:"id"` - BlockConfigID uuid.UUID `json:"block_config_id"` - ModuleID uuid.UUID `json:"module_id"` - ControlID string `json:"control_id"` - ControlTitle string `json:"control_title"` - ControlObjective string `json:"control_objective"` - ControlRequirements []string `json:"control_requirements"` - SortOrder int `json:"sort_order"` - CreatedAt time.Time `json:"created_at"` -} - -// CanonicalControlSummary is a lightweight view on canonical_controls for the training pipeline -type CanonicalControlSummary struct { - ControlID string `json:"control_id"` - Title string `json:"title"` - Objective string `json:"objective"` - Rationale string `json:"rationale"` - Requirements []string `json:"requirements"` - Severity string `json:"severity"` - Category string `json:"category"` - TargetAudience string `json:"target_audience"` - Tags []string `json:"tags"` -} - -// CanonicalControlMeta provides aggregated metadata about canonical controls -type CanonicalControlMeta struct { - Domains []DomainCount `json:"domains"` - Categories []CategoryCount `json:"categories"` - Audiences []AudienceCount `json:"audiences"` - Total int `json:"total"` -} - -// DomainCount is a domain with its control count -type DomainCount struct { - Domain string `json:"domain"` - Count int `json:"count"` -} - -// CategoryCount is a category with its control count -type CategoryCount struct { - Category string `json:"category"` - Count int `json:"count"` -} - -// AudienceCount is a target audience with its control count -type AudienceCount struct { - Audience string `json:"audience"` - Count int `json:"count"` -} - -// CreateBlockConfigRequest is the API request for creating a block config -type CreateBlockConfigRequest struct { - Name string `json:"name" binding:"required"` - Description string `json:"description,omitempty"` - DomainFilter string `json:"domain_filter,omitempty"` - CategoryFilter string `json:"category_filter,omitempty"` - SeverityFilter string `json:"severity_filter,omitempty"` - TargetAudienceFilter string `json:"target_audience_filter,omitempty"` - RegulationArea RegulationArea `json:"regulation_area" binding:"required"` - ModuleCodePrefix string `json:"module_code_prefix" binding:"required"` - FrequencyType FrequencyType `json:"frequency_type"` - DurationMinutes int `json:"duration_minutes"` - PassThreshold int `json:"pass_threshold"` - MaxControlsPerModule int `json:"max_controls_per_module"` -} - -// UpdateBlockConfigRequest is the API request for updating a block config -type UpdateBlockConfigRequest struct { - Name *string `json:"name,omitempty"` - Description *string `json:"description,omitempty"` - DomainFilter *string `json:"domain_filter,omitempty"` - CategoryFilter *string `json:"category_filter,omitempty"` - SeverityFilter *string `json:"severity_filter,omitempty"` - TargetAudienceFilter *string `json:"target_audience_filter,omitempty"` - MaxControlsPerModule *int `json:"max_controls_per_module,omitempty"` - DurationMinutes *int `json:"duration_minutes,omitempty"` - PassThreshold *int `json:"pass_threshold,omitempty"` - IsActive *bool `json:"is_active,omitempty"` -} - -// ============================================================================ -// Interactive Video / Checkpoint Types -// ============================================================================ - -// NarratorScript is an extended VideoScript with narrator persona and checkpoints -type NarratorScript struct { - Title string `json:"title"` - Intro string `json:"intro"` - Sections []NarratorSection `json:"sections"` - Outro string `json:"outro"` - TotalDurationEstimate int `json:"total_duration_estimate"` -} - -// NarratorSection is one narrative section with optional checkpoint -type NarratorSection struct { - Heading string `json:"heading"` - NarratorText string `json:"narrator_text"` - BulletPoints []string `json:"bullet_points"` - Transition string `json:"transition"` - Checkpoint *CheckpointDefinition `json:"checkpoint,omitempty"` -} - -// CheckpointDefinition defines a quiz checkpoint within a video -type CheckpointDefinition struct { - Title string `json:"title"` - Questions []CheckpointQuestion `json:"questions"` -} - -// CheckpointQuestion is a quiz question within a checkpoint -type CheckpointQuestion struct { - Question string `json:"question"` - Options []string `json:"options"` - CorrectIndex int `json:"correct_index"` - Explanation string `json:"explanation"` -} - -// Checkpoint is a DB record for a video checkpoint -type Checkpoint struct { - ID uuid.UUID `json:"id"` - ModuleID uuid.UUID `json:"module_id"` - CheckpointIndex int `json:"checkpoint_index"` - Title string `json:"title"` - TimestampSeconds float64 `json:"timestamp_seconds"` - CreatedAt time.Time `json:"created_at"` -} - -// CheckpointProgress tracks a user's progress on a checkpoint -type CheckpointProgress struct { - ID uuid.UUID `json:"id"` - AssignmentID uuid.UUID `json:"assignment_id"` - CheckpointID uuid.UUID `json:"checkpoint_id"` - Passed bool `json:"passed"` - Attempts int `json:"attempts"` - LastAttemptAt *time.Time `json:"last_attempt_at,omitempty"` - CreatedAt time.Time `json:"created_at"` -} - -// InteractiveVideoManifest is returned to the frontend player -type InteractiveVideoManifest struct { - MediaID uuid.UUID `json:"media_id"` - StreamURL string `json:"stream_url"` - Checkpoints []CheckpointManifestEntry `json:"checkpoints"` -} - -// CheckpointManifestEntry is one checkpoint in the manifest -type CheckpointManifestEntry struct { - CheckpointID uuid.UUID `json:"checkpoint_id"` - Index int `json:"index"` - Title string `json:"title"` - TimestampSeconds float64 `json:"timestamp_seconds"` - Questions []CheckpointQuestion `json:"questions"` - Progress *CheckpointProgress `json:"progress,omitempty"` -} - -// SubmitCheckpointQuizRequest is the API request for submitting a checkpoint quiz -type SubmitCheckpointQuizRequest struct { - AssignmentID string `json:"assignment_id"` - Answers []int `json:"answers"` -} - -// SubmitCheckpointQuizResponse is the API response for a checkpoint quiz submission -type SubmitCheckpointQuizResponse struct { - Passed bool `json:"passed"` - Score float64 `json:"score"` - Feedback []CheckpointQuizFeedback `json:"feedback"` -} - -// CheckpointQuizFeedback is feedback for a single question -type CheckpointQuizFeedback struct { - Question string `json:"question"` - Correct bool `json:"correct"` - Explanation string `json:"explanation"` -} - -// GenerateBlockRequest is the API request for generating modules from a block config -type GenerateBlockRequest struct { - Language string `json:"language"` - AutoMatrix bool `json:"auto_matrix"` -} - -// PreviewBlockResponse shows what would be generated without writing to DB -type PreviewBlockResponse struct { - ControlCount int `json:"control_count"` - ModuleCount int `json:"module_count"` - Controls []CanonicalControlSummary `json:"controls"` - ProposedRoles []string `json:"proposed_roles"` -} - -// GenerateBlockResponse shows the result of a block generation -type GenerateBlockResponse struct { - ModulesCreated int `json:"modules_created"` - ControlsLinked int `json:"controls_linked"` - MatrixEntriesCreated int `json:"matrix_entries_created"` - ContentGenerated int `json:"content_generated"` - Errors []string `json:"errors,omitempty"` -} diff --git a/ai-compliance-sdk/internal/training/models_api.go b/ai-compliance-sdk/internal/training/models_api.go new file mode 100644 index 0000000..2dc299d --- /dev/null +++ b/ai-compliance-sdk/internal/training/models_api.go @@ -0,0 +1,141 @@ +package training + +import ( + "time" + + "github.com/google/uuid" +) + +// ============================================================================ +// API Request/Response Types +// ============================================================================ + +// CreateModuleRequest is the API request for creating a training module +type CreateModuleRequest struct { + ModuleCode string `json:"module_code" binding:"required"` + Title string `json:"title" binding:"required"` + Description string `json:"description,omitempty"` + RegulationArea RegulationArea `json:"regulation_area" binding:"required"` + NIS2Relevant bool `json:"nis2_relevant"` + ISOControls []string `json:"iso_controls,omitempty"` + FrequencyType FrequencyType `json:"frequency_type" binding:"required"` + ValidityDays int `json:"validity_days"` + RiskWeight float64 `json:"risk_weight"` + ContentType string `json:"content_type"` + DurationMinutes int `json:"duration_minutes"` + PassThreshold int `json:"pass_threshold"` +} + +// UpdateModuleRequest is the API request for updating a training module +type UpdateModuleRequest struct { + Title *string `json:"title,omitempty"` + Description *string `json:"description,omitempty"` + NIS2Relevant *bool `json:"nis2_relevant,omitempty"` + ISOControls []string `json:"iso_controls,omitempty"` + ValidityDays *int `json:"validity_days,omitempty"` + RiskWeight *float64 `json:"risk_weight,omitempty"` + DurationMinutes *int `json:"duration_minutes,omitempty"` + PassThreshold *int `json:"pass_threshold,omitempty"` + IsActive *bool `json:"is_active,omitempty"` +} + +// SetMatrixEntryRequest is the API request for setting a CTM entry +type SetMatrixEntryRequest struct { + RoleCode string `json:"role_code" binding:"required"` + ModuleID uuid.UUID `json:"module_id" binding:"required"` + IsMandatory bool `json:"is_mandatory"` + Priority int `json:"priority"` +} + +// ComputeAssignmentsRequest is the API request for computing assignments +type ComputeAssignmentsRequest struct { + UserID uuid.UUID `json:"user_id" binding:"required"` + UserName string `json:"user_name" binding:"required"` + UserEmail string `json:"user_email" binding:"required"` + Roles []string `json:"roles" binding:"required"` + Trigger string `json:"trigger"` +} + +// UpdateAssignmentProgressRequest updates progress on an assignment +type UpdateAssignmentProgressRequest struct { + Progress int `json:"progress" binding:"required"` +} + +// SubmitTrainingQuizRequest is the API request for submitting a quiz +type SubmitTrainingQuizRequest struct { + AssignmentID uuid.UUID `json:"assignment_id" binding:"required"` + Answers []QuizAnswer `json:"answers" binding:"required"` + DurationSeconds *int `json:"duration_seconds,omitempty"` +} + +// SubmitTrainingQuizResponse is the API response for quiz submission +type SubmitTrainingQuizResponse struct { + AttemptID uuid.UUID `json:"attempt_id"` + Score float64 `json:"score"` + Passed bool `json:"passed"` + CorrectCount int `json:"correct_count"` + TotalCount int `json:"total_count"` + Threshold int `json:"threshold"` +} + +// GenerateContentRequest is the API request for LLM content generation +type GenerateContentRequest struct { + ModuleID uuid.UUID `json:"module_id" binding:"required"` + Language string `json:"language"` +} + +// GenerateQuizRequest is the API request for LLM quiz generation +type GenerateQuizRequest struct { + ModuleID uuid.UUID `json:"module_id" binding:"required"` + Count int `json:"count"` +} + +// PublishContentRequest is the API request for publishing content +type PublishContentRequest struct { + ReviewedBy uuid.UUID `json:"reviewed_by"` +} + +// BulkAssignRequest is the API request for bulk assigning a module +type BulkAssignRequest struct { + ModuleID uuid.UUID `json:"module_id" binding:"required"` + RoleCodes []string `json:"role_codes" binding:"required"` + Trigger string `json:"trigger"` + Deadline time.Time `json:"deadline" binding:"required"` +} + +// ModuleListResponse is the API response for listing modules +type ModuleListResponse struct { + Modules []TrainingModule `json:"modules"` + Total int `json:"total"` +} + +// AssignmentListResponse is the API response for listing assignments +type AssignmentListResponse struct { + Assignments []TrainingAssignment `json:"assignments"` + Total int `json:"total"` +} + +// MatrixResponse is the API response for the full training matrix +type MatrixResponse struct { + Entries map[string][]TrainingMatrixEntry `json:"entries"` // role_code -> entries + Roles map[string]string `json:"roles"` // role_code -> label +} + +// AuditLogResponse is the API response for listing audit log entries +type AuditLogResponse struct { + Entries []AuditLogEntry `json:"entries"` + Total int `json:"total"` +} + +// EscalationResponse is the API response for escalation check +type EscalationResponse struct { + Results []EscalationResult `json:"results"` + TotalChecked int `json:"total_checked"` + Escalated int `json:"escalated"` +} + +// DeadlineListResponse is the API response for listing deadlines +type DeadlineListResponse struct { + Deadlines []DeadlineInfo `json:"deadlines"` + Total int `json:"total"` +} diff --git a/ai-compliance-sdk/internal/training/models_blocks.go b/ai-compliance-sdk/internal/training/models_blocks.go new file mode 100644 index 0000000..55f0928 --- /dev/null +++ b/ai-compliance-sdk/internal/training/models_blocks.go @@ -0,0 +1,193 @@ +package training + +import ( + "time" + + "github.com/google/uuid" +) + +// ============================================================================ +// Training Block Types (Controls → Schulungsmodule Pipeline) +// ============================================================================ + +// TrainingBlockConfig defines how canonical controls are grouped into training modules +type TrainingBlockConfig struct { + ID uuid.UUID `json:"id"` + TenantID uuid.UUID `json:"tenant_id"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + DomainFilter string `json:"domain_filter,omitempty"` + CategoryFilter string `json:"category_filter,omitempty"` + SeverityFilter string `json:"severity_filter,omitempty"` + TargetAudienceFilter string `json:"target_audience_filter,omitempty"` + RegulationArea RegulationArea `json:"regulation_area"` + ModuleCodePrefix string `json:"module_code_prefix"` + FrequencyType FrequencyType `json:"frequency_type"` + DurationMinutes int `json:"duration_minutes"` + PassThreshold int `json:"pass_threshold"` + MaxControlsPerModule int `json:"max_controls_per_module"` + IsActive bool `json:"is_active"` + LastGeneratedAt *time.Time `json:"last_generated_at,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// TrainingBlockControlLink tracks which canonical controls are linked to which module +type TrainingBlockControlLink struct { + ID uuid.UUID `json:"id"` + BlockConfigID uuid.UUID `json:"block_config_id"` + ModuleID uuid.UUID `json:"module_id"` + ControlID string `json:"control_id"` + ControlTitle string `json:"control_title"` + ControlObjective string `json:"control_objective"` + ControlRequirements []string `json:"control_requirements"` + SortOrder int `json:"sort_order"` + CreatedAt time.Time `json:"created_at"` +} + +// CreateBlockConfigRequest is the API request for creating a block config +type CreateBlockConfigRequest struct { + Name string `json:"name" binding:"required"` + Description string `json:"description,omitempty"` + DomainFilter string `json:"domain_filter,omitempty"` + CategoryFilter string `json:"category_filter,omitempty"` + SeverityFilter string `json:"severity_filter,omitempty"` + TargetAudienceFilter string `json:"target_audience_filter,omitempty"` + RegulationArea RegulationArea `json:"regulation_area" binding:"required"` + ModuleCodePrefix string `json:"module_code_prefix" binding:"required"` + FrequencyType FrequencyType `json:"frequency_type"` + DurationMinutes int `json:"duration_minutes"` + PassThreshold int `json:"pass_threshold"` + MaxControlsPerModule int `json:"max_controls_per_module"` +} + +// UpdateBlockConfigRequest is the API request for updating a block config +type UpdateBlockConfigRequest struct { + Name *string `json:"name,omitempty"` + Description *string `json:"description,omitempty"` + DomainFilter *string `json:"domain_filter,omitempty"` + CategoryFilter *string `json:"category_filter,omitempty"` + SeverityFilter *string `json:"severity_filter,omitempty"` + TargetAudienceFilter *string `json:"target_audience_filter,omitempty"` + MaxControlsPerModule *int `json:"max_controls_per_module,omitempty"` + DurationMinutes *int `json:"duration_minutes,omitempty"` + PassThreshold *int `json:"pass_threshold,omitempty"` + IsActive *bool `json:"is_active,omitempty"` +} + +// GenerateBlockRequest is the API request for generating modules from a block config +type GenerateBlockRequest struct { + Language string `json:"language"` + AutoMatrix bool `json:"auto_matrix"` +} + +// PreviewBlockResponse shows what would be generated without writing to DB +type PreviewBlockResponse struct { + ControlCount int `json:"control_count"` + ModuleCount int `json:"module_count"` + Controls []CanonicalControlSummary `json:"controls"` + ProposedRoles []string `json:"proposed_roles"` +} + +// GenerateBlockResponse shows the result of a block generation +type GenerateBlockResponse struct { + ModulesCreated int `json:"modules_created"` + ControlsLinked int `json:"controls_linked"` + MatrixEntriesCreated int `json:"matrix_entries_created"` + ContentGenerated int `json:"content_generated"` + Errors []string `json:"errors,omitempty"` +} + +// ============================================================================ +// Interactive Video / Checkpoint Types +// ============================================================================ + +// NarratorScript is an extended VideoScript with narrator persona and checkpoints +type NarratorScript struct { + Title string `json:"title"` + Intro string `json:"intro"` + Sections []NarratorSection `json:"sections"` + Outro string `json:"outro"` + TotalDurationEstimate int `json:"total_duration_estimate"` +} + +// NarratorSection is one narrative section with optional checkpoint +type NarratorSection struct { + Heading string `json:"heading"` + NarratorText string `json:"narrator_text"` + BulletPoints []string `json:"bullet_points"` + Transition string `json:"transition"` + Checkpoint *CheckpointDefinition `json:"checkpoint,omitempty"` +} + +// CheckpointDefinition defines a quiz checkpoint within a video +type CheckpointDefinition struct { + Title string `json:"title"` + Questions []CheckpointQuestion `json:"questions"` +} + +// CheckpointQuestion is a quiz question within a checkpoint +type CheckpointQuestion struct { + Question string `json:"question"` + Options []string `json:"options"` + CorrectIndex int `json:"correct_index"` + Explanation string `json:"explanation"` +} + +// Checkpoint is a DB record for a video checkpoint +type Checkpoint struct { + ID uuid.UUID `json:"id"` + ModuleID uuid.UUID `json:"module_id"` + CheckpointIndex int `json:"checkpoint_index"` + Title string `json:"title"` + TimestampSeconds float64 `json:"timestamp_seconds"` + CreatedAt time.Time `json:"created_at"` +} + +// CheckpointProgress tracks a user's progress on a checkpoint +type CheckpointProgress struct { + ID uuid.UUID `json:"id"` + AssignmentID uuid.UUID `json:"assignment_id"` + CheckpointID uuid.UUID `json:"checkpoint_id"` + Passed bool `json:"passed"` + Attempts int `json:"attempts"` + LastAttemptAt *time.Time `json:"last_attempt_at,omitempty"` + CreatedAt time.Time `json:"created_at"` +} + +// InteractiveVideoManifest is returned to the frontend player +type InteractiveVideoManifest struct { + MediaID uuid.UUID `json:"media_id"` + StreamURL string `json:"stream_url"` + Checkpoints []CheckpointManifestEntry `json:"checkpoints"` +} + +// CheckpointManifestEntry is one checkpoint in the manifest +type CheckpointManifestEntry struct { + CheckpointID uuid.UUID `json:"checkpoint_id"` + Index int `json:"index"` + Title string `json:"title"` + TimestampSeconds float64 `json:"timestamp_seconds"` + Questions []CheckpointQuestion `json:"questions"` + Progress *CheckpointProgress `json:"progress,omitempty"` +} + +// SubmitCheckpointQuizRequest is the API request for submitting a checkpoint quiz +type SubmitCheckpointQuizRequest struct { + AssignmentID string `json:"assignment_id"` + Answers []int `json:"answers"` +} + +// SubmitCheckpointQuizResponse is the API response for a checkpoint quiz submission +type SubmitCheckpointQuizResponse struct { + Passed bool `json:"passed"` + Score float64 `json:"score"` + Feedback []CheckpointQuizFeedback `json:"feedback"` +} + +// CheckpointQuizFeedback is feedback for a single question +type CheckpointQuizFeedback struct { + Question string `json:"question"` + Correct bool `json:"correct"` + Explanation string `json:"explanation"` +} diff --git a/ai-compliance-sdk/internal/training/models_core.go b/ai-compliance-sdk/internal/training/models_core.go new file mode 100644 index 0000000..d59a861 --- /dev/null +++ b/ai-compliance-sdk/internal/training/models_core.go @@ -0,0 +1,276 @@ +package training + +import ( + "time" + + "github.com/google/uuid" +) + +// ============================================================================ +// Main Entities +// ============================================================================ + +// TrainingModule represents a compliance training module +type TrainingModule struct { + ID uuid.UUID `json:"id"` + TenantID uuid.UUID `json:"tenant_id"` + AcademyCourseID *uuid.UUID `json:"academy_course_id,omitempty"` + ModuleCode string `json:"module_code"` + Title string `json:"title"` + Description string `json:"description,omitempty"` + RegulationArea RegulationArea `json:"regulation_area"` + NIS2Relevant bool `json:"nis2_relevant"` + ISOControls []string `json:"iso_controls"` // JSONB + FrequencyType FrequencyType `json:"frequency_type"` + ValidityDays int `json:"validity_days"` + RiskWeight float64 `json:"risk_weight"` + ContentType string `json:"content_type"` + DurationMinutes int `json:"duration_minutes"` + PassThreshold int `json:"pass_threshold"` + IsActive bool `json:"is_active"` + SortOrder int `json:"sort_order"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// TrainingMatrixEntry represents a role-to-module mapping in the CTM +type TrainingMatrixEntry struct { + ID uuid.UUID `json:"id"` + TenantID uuid.UUID `json:"tenant_id"` + RoleCode string `json:"role_code"` + ModuleID uuid.UUID `json:"module_id"` + IsMandatory bool `json:"is_mandatory"` + Priority int `json:"priority"` + CreatedAt time.Time `json:"created_at"` + // Joined fields (optional, populated in queries) + ModuleCode string `json:"module_code,omitempty"` + ModuleTitle string `json:"module_title,omitempty"` +} + +// TrainingAssignment represents a user's training assignment +type TrainingAssignment struct { + ID uuid.UUID `json:"id"` + TenantID uuid.UUID `json:"tenant_id"` + ModuleID uuid.UUID `json:"module_id"` + UserID uuid.UUID `json:"user_id"` + UserName string `json:"user_name"` + UserEmail string `json:"user_email"` + RoleCode string `json:"role_code,omitempty"` + TriggerType TriggerType `json:"trigger_type"` + TriggerEvent string `json:"trigger_event,omitempty"` + Status AssignmentStatus `json:"status"` + ProgressPercent int `json:"progress_percent"` + QuizScore *float64 `json:"quiz_score,omitempty"` + QuizPassed *bool `json:"quiz_passed,omitempty"` + QuizAttempts int `json:"quiz_attempts"` + StartedAt *time.Time `json:"started_at,omitempty"` + CompletedAt *time.Time `json:"completed_at,omitempty"` + Deadline time.Time `json:"deadline"` + CertificateID *uuid.UUID `json:"certificate_id,omitempty"` + EscalationLevel int `json:"escalation_level"` + LastEscalationAt *time.Time `json:"last_escalation_at,omitempty"` + EnrollmentID *uuid.UUID `json:"enrollment_id,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + // Joined fields + ModuleCode string `json:"module_code,omitempty"` + ModuleTitle string `json:"module_title,omitempty"` +} + +// QuizQuestion represents a persistent quiz question for a module +type QuizQuestion struct { + ID uuid.UUID `json:"id"` + ModuleID uuid.UUID `json:"module_id"` + Question string `json:"question"` + Options []string `json:"options"` // JSONB + CorrectIndex int `json:"correct_index"` + Explanation string `json:"explanation,omitempty"` + Difficulty Difficulty `json:"difficulty"` + IsActive bool `json:"is_active"` + SortOrder int `json:"sort_order"` + CreatedAt time.Time `json:"created_at"` +} + +// QuizAttempt represents a single quiz attempt by a user +type QuizAttempt struct { + ID uuid.UUID `json:"id"` + AssignmentID uuid.UUID `json:"assignment_id"` + UserID uuid.UUID `json:"user_id"` + Answers []QuizAnswer `json:"answers"` // JSONB + Score float64 `json:"score"` + Passed bool `json:"passed"` + CorrectCount int `json:"correct_count"` + TotalCount int `json:"total_count"` + DurationSeconds *int `json:"duration_seconds,omitempty"` + AttemptedAt time.Time `json:"attempted_at"` +} + +// QuizAnswer represents a single answer within a quiz attempt +type QuizAnswer struct { + QuestionID uuid.UUID `json:"question_id"` + SelectedIndex int `json:"selected_index"` + Correct bool `json:"correct"` +} + +// AuditLogEntry represents an entry in the training audit trail +type AuditLogEntry struct { + ID uuid.UUID `json:"id"` + TenantID uuid.UUID `json:"tenant_id"` + UserID *uuid.UUID `json:"user_id,omitempty"` + Action AuditAction `json:"action"` + EntityType AuditEntityType `json:"entity_type"` + EntityID *uuid.UUID `json:"entity_id,omitempty"` + Details map[string]interface{} `json:"details"` // JSONB + IPAddress string `json:"ip_address,omitempty"` + CreatedAt time.Time `json:"created_at"` +} + +// ModuleContent represents LLM-generated or manual content for a module +type ModuleContent struct { + ID uuid.UUID `json:"id"` + ModuleID uuid.UUID `json:"module_id"` + Version int `json:"version"` + ContentFormat ContentFormat `json:"content_format"` + ContentBody string `json:"content_body"` + Summary string `json:"summary,omitempty"` + GeneratedBy string `json:"generated_by,omitempty"` + LLMModel string `json:"llm_model,omitempty"` + IsPublished bool `json:"is_published"` + ReviewedBy *uuid.UUID `json:"reviewed_by,omitempty"` + ReviewedAt *time.Time `json:"reviewed_at,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// TrainingStats contains aggregated training metrics +type TrainingStats struct { + TotalModules int `json:"total_modules"` + TotalAssignments int `json:"total_assignments"` + CompletionRate float64 `json:"completion_rate"` + OverdueCount int `json:"overdue_count"` + PendingCount int `json:"pending_count"` + InProgressCount int `json:"in_progress_count"` + CompletedCount int `json:"completed_count"` + AvgQuizScore float64 `json:"avg_quiz_score"` + AvgCompletionDays float64 `json:"avg_completion_days"` + UpcomingDeadlines int `json:"upcoming_deadlines"` // within 7 days +} + +// ComplianceGap represents a missing or overdue training requirement +type ComplianceGap struct { + ModuleID uuid.UUID `json:"module_id"` + ModuleCode string `json:"module_code"` + ModuleTitle string `json:"module_title"` + RegulationArea RegulationArea `json:"regulation_area"` + RoleCode string `json:"role_code"` + IsMandatory bool `json:"is_mandatory"` + AssignmentID *uuid.UUID `json:"assignment_id,omitempty"` + Status string `json:"status"` // "missing", "overdue", "expired" + Deadline *time.Time `json:"deadline,omitempty"` +} + +// EscalationResult represents the result of an escalation check +type EscalationResult struct { + AssignmentID uuid.UUID `json:"assignment_id"` + UserID uuid.UUID `json:"user_id"` + UserName string `json:"user_name"` + UserEmail string `json:"user_email"` + ModuleTitle string `json:"module_title"` + PreviousLevel int `json:"previous_level"` + NewLevel int `json:"new_level"` + DaysOverdue int `json:"days_overdue"` + EscalationLabel string `json:"escalation_label"` +} + +// DeadlineInfo represents upcoming deadline information +type DeadlineInfo struct { + AssignmentID uuid.UUID `json:"assignment_id"` + ModuleCode string `json:"module_code"` + ModuleTitle string `json:"module_title"` + UserID uuid.UUID `json:"user_id"` + UserName string `json:"user_name"` + Deadline time.Time `json:"deadline"` + DaysLeft int `json:"days_left"` + Status AssignmentStatus `json:"status"` +} + +// ============================================================================ +// Filter Types +// ============================================================================ + +// ModuleFilters defines filters for listing modules +type ModuleFilters struct { + RegulationArea RegulationArea + FrequencyType FrequencyType + IsActive *bool + NIS2Relevant *bool + Search string + Limit int + Offset int +} + +// AssignmentFilters defines filters for listing assignments +type AssignmentFilters struct { + ModuleID *uuid.UUID + UserID *uuid.UUID + RoleCode string + Status AssignmentStatus + Overdue *bool + Limit int + Offset int +} + +// AuditLogFilters defines filters for listing audit log entries +type AuditLogFilters struct { + UserID *uuid.UUID + Action AuditAction + EntityType AuditEntityType + Limit int + Offset int +} + +// CanonicalControlSummary is a lightweight view on canonical_controls for the training pipeline +type CanonicalControlSummary struct { + ControlID string `json:"control_id"` + Title string `json:"title"` + Objective string `json:"objective"` + Rationale string `json:"rationale"` + Requirements []string `json:"requirements"` + Severity string `json:"severity"` + Category string `json:"category"` + TargetAudience string `json:"target_audience"` + Tags []string `json:"tags"` +} + +// CanonicalControlMeta provides aggregated metadata about canonical controls +type CanonicalControlMeta struct { + Domains []DomainCount `json:"domains"` + Categories []CategoryCount `json:"categories"` + Audiences []AudienceCount `json:"audiences"` + Total int `json:"total"` +} + +// DomainCount is a domain with its control count +type DomainCount struct { + Domain string `json:"domain"` + Count int `json:"count"` +} + +// CategoryCount is a category with its control count +type CategoryCount struct { + Category string `json:"category"` + Count int `json:"count"` +} + +// AudienceCount is a target audience with its control count +type AudienceCount struct { + Audience string `json:"audience"` + Count int `json:"count"` +} + +// BulkResult holds the result of a bulk generation operation +type BulkResult struct { + Generated int `json:"generated"` + Skipped int `json:"skipped"` + Errors []string `json:"errors"` +} diff --git a/ai-compliance-sdk/internal/training/models_enums.go b/ai-compliance-sdk/internal/training/models_enums.go new file mode 100644 index 0000000..b555bd0 --- /dev/null +++ b/ai-compliance-sdk/internal/training/models_enums.go @@ -0,0 +1,162 @@ +package training + +// ============================================================================ +// Constants / Enums +// ============================================================================ + +// RegulationArea represents a compliance regulation area +type RegulationArea string + +const ( + RegulationDSGVO RegulationArea = "dsgvo" + RegulationNIS2 RegulationArea = "nis2" + RegulationISO27001 RegulationArea = "iso27001" + RegulationAIAct RegulationArea = "ai_act" + RegulationGeschGehG RegulationArea = "geschgehg" + RegulationHinSchG RegulationArea = "hinschg" +) + +// FrequencyType represents the training frequency +type FrequencyType string + +const ( + FrequencyOnboarding FrequencyType = "onboarding" + FrequencyAnnual FrequencyType = "annual" + FrequencyEventTrigger FrequencyType = "event_trigger" + FrequencyMicro FrequencyType = "micro" +) + +// AssignmentStatus represents the status of a training assignment +type AssignmentStatus string + +const ( + AssignmentStatusPending AssignmentStatus = "pending" + AssignmentStatusInProgress AssignmentStatus = "in_progress" + AssignmentStatusCompleted AssignmentStatus = "completed" + AssignmentStatusOverdue AssignmentStatus = "overdue" + AssignmentStatusExpired AssignmentStatus = "expired" +) + +// TriggerType represents how a training was assigned +type TriggerType string + +const ( + TriggerOnboarding TriggerType = "onboarding" + TriggerAnnual TriggerType = "annual" + TriggerEvent TriggerType = "event" + TriggerManual TriggerType = "manual" +) + +// ContentFormat represents the format of module content +type ContentFormat string + +const ( + ContentFormatMarkdown ContentFormat = "markdown" + ContentFormatHTML ContentFormat = "html" +) + +// Difficulty represents the difficulty level of a quiz question +type Difficulty string + +const ( + DifficultyEasy Difficulty = "easy" + DifficultyMedium Difficulty = "medium" + DifficultyHard Difficulty = "hard" +) + +// AuditAction represents an action in the audit trail +type AuditAction string + +const ( + AuditActionAssigned AuditAction = "assigned" + AuditActionStarted AuditAction = "started" + AuditActionCompleted AuditAction = "completed" + AuditActionQuizSubmitted AuditAction = "quiz_submitted" + AuditActionEscalated AuditAction = "escalated" + AuditActionCertificateIssued AuditAction = "certificate_issued" + AuditActionContentGenerated AuditAction = "content_generated" +) + +// AuditEntityType represents the type of entity in audit log +type AuditEntityType string + +const ( + AuditEntityAssignment AuditEntityType = "assignment" + AuditEntityModule AuditEntityType = "module" + AuditEntityQuiz AuditEntityType = "quiz" + AuditEntityCertificate AuditEntityType = "certificate" +) + +// ============================================================================ +// Role Constants +// ============================================================================ + +const ( + RoleR1 = "R1" // Geschaeftsfuehrung + RoleR2 = "R2" // IT-Leitung + RoleR3 = "R3" // DSB + RoleR4 = "R4" // ISB + RoleR5 = "R5" // HR + RoleR6 = "R6" // Einkauf + RoleR7 = "R7" // Fachabteilung + RoleR8 = "R8" // IT-Admin + RoleR9 = "R9" // Alle Mitarbeiter + RoleR10 = "R10" // Behoerden / Oeffentlicher Dienst +) + +// RoleLabels maps role codes to human-readable labels +var RoleLabels = map[string]string{ + RoleR1: "Geschaeftsfuehrung", + RoleR2: "IT-Leitung", + RoleR3: "Datenschutzbeauftragter", + RoleR4: "Informationssicherheitsbeauftragter", + RoleR5: "HR / Personal", + RoleR6: "Einkauf / Beschaffung", + RoleR7: "Fachabteilung", + RoleR8: "IT-Administration", + RoleR9: "Alle Mitarbeiter", + RoleR10: "Behoerden / Oeffentlicher Dienst", +} + +// NIS2RoleMapping maps internal roles to NIS2 levels +var NIS2RoleMapping = map[string]string{ + RoleR1: "N1", // Geschaeftsfuehrung + RoleR2: "N2", // IT-Leitung + RoleR3: "N3", // DSB + RoleR4: "N3", // ISB + RoleR5: "N4", // HR + RoleR6: "N4", // Einkauf + RoleR7: "N5", // Fachabteilung + RoleR8: "N2", // IT-Admin + RoleR9: "N5", // Alle Mitarbeiter + RoleR10: "N4", // Behoerden +} + +// TargetAudienceRoleMapping maps canonical control target_audience values to CTM roles +var TargetAudienceRoleMapping = map[string][]string{ + "enterprise": {RoleR1, RoleR4, RoleR5, RoleR6, RoleR7, RoleR9}, + "authority": {RoleR10}, + "provider": {RoleR2, RoleR8}, + "all": {RoleR1, RoleR2, RoleR3, RoleR4, RoleR5, RoleR6, RoleR7, RoleR8, RoleR9, RoleR10}, +} + +// CategoryRoleMapping provides additional role hints based on control category +var CategoryRoleMapping = map[string][]string{ + "encryption": {RoleR2, RoleR8}, + "authentication": {RoleR2, RoleR8, RoleR9}, + "network": {RoleR2, RoleR8}, + "data_protection": {RoleR3, RoleR5, RoleR9}, + "logging": {RoleR2, RoleR4, RoleR8}, + "incident": {RoleR1, RoleR4}, + "continuity": {RoleR1, RoleR2, RoleR4}, + "compliance": {RoleR1, RoleR3, RoleR4}, + "supply_chain": {RoleR6}, + "physical": {RoleR7}, + "personnel": {RoleR5, RoleR9}, + "application": {RoleR8}, + "system": {RoleR2, RoleR8}, + "risk": {RoleR1, RoleR4}, + "governance": {RoleR1, RoleR4}, + "hardware": {RoleR2, RoleR8}, + "identity": {RoleR2, RoleR3, RoleR8}, +} diff --git a/ai-compliance-sdk/internal/workshop/store.go b/ai-compliance-sdk/internal/workshop/store.go deleted file mode 100644 index 72e2e1e..0000000 --- a/ai-compliance-sdk/internal/workshop/store.go +++ /dev/null @@ -1,793 +0,0 @@ -package workshop - -import ( - "context" - "crypto/rand" - "encoding/base32" - "encoding/json" - "fmt" - "strings" - "time" - - "github.com/google/uuid" - "github.com/jackc/pgx/v5" - "github.com/jackc/pgx/v5/pgxpool" -) - -// Store handles workshop session data persistence -type Store struct { - pool *pgxpool.Pool -} - -// NewStore creates a new workshop store -func NewStore(pool *pgxpool.Pool) *Store { - return &Store{pool: pool} -} - -// ============================================================================ -// Session CRUD Operations -// ============================================================================ - -// CreateSession creates a new workshop session -func (s *Store) CreateSession(ctx context.Context, session *Session) error { - session.ID = uuid.New() - session.CreatedAt = time.Now().UTC() - session.UpdatedAt = session.CreatedAt - if session.Status == "" { - session.Status = SessionStatusDraft - } - if session.JoinCode == "" { - session.JoinCode = generateJoinCode() - } - - settings, _ := json.Marshal(session.Settings) - - _, err := s.pool.Exec(ctx, ` - INSERT INTO workshop_sessions ( - id, tenant_id, namespace_id, - title, description, session_type, status, - wizard_schema, current_step, total_steps, - assessment_id, roadmap_id, portfolio_id, - scheduled_start, scheduled_end, actual_start, actual_end, - join_code, require_auth, allow_anonymous, - settings, - created_at, updated_at, created_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 - ) - `, - session.ID, session.TenantID, session.NamespaceID, - session.Title, session.Description, session.SessionType, string(session.Status), - session.WizardSchema, session.CurrentStep, session.TotalSteps, - session.AssessmentID, session.RoadmapID, session.PortfolioID, - session.ScheduledStart, session.ScheduledEnd, session.ActualStart, session.ActualEnd, - session.JoinCode, session.RequireAuth, session.AllowAnonymous, - settings, - session.CreatedAt, session.UpdatedAt, session.CreatedBy, - ) - - return err -} - -// GetSession retrieves a session by ID -func (s *Store) GetSession(ctx context.Context, id uuid.UUID) (*Session, error) { - var session Session - var status string - var settings []byte - - err := s.pool.QueryRow(ctx, ` - SELECT - id, tenant_id, namespace_id, - title, description, session_type, status, - wizard_schema, current_step, total_steps, - assessment_id, roadmap_id, portfolio_id, - scheduled_start, scheduled_end, actual_start, actual_end, - join_code, require_auth, allow_anonymous, - settings, - created_at, updated_at, created_by - FROM workshop_sessions WHERE id = $1 - `, id).Scan( - &session.ID, &session.TenantID, &session.NamespaceID, - &session.Title, &session.Description, &session.SessionType, &status, - &session.WizardSchema, &session.CurrentStep, &session.TotalSteps, - &session.AssessmentID, &session.RoadmapID, &session.PortfolioID, - &session.ScheduledStart, &session.ScheduledEnd, &session.ActualStart, &session.ActualEnd, - &session.JoinCode, &session.RequireAuth, &session.AllowAnonymous, - &settings, - &session.CreatedAt, &session.UpdatedAt, &session.CreatedBy, - ) - - if err == pgx.ErrNoRows { - return nil, nil - } - if err != nil { - return nil, err - } - - session.Status = SessionStatus(status) - json.Unmarshal(settings, &session.Settings) - - return &session, nil -} - -// GetSessionByJoinCode retrieves a session by its join code -func (s *Store) GetSessionByJoinCode(ctx context.Context, code string) (*Session, error) { - var id uuid.UUID - err := s.pool.QueryRow(ctx, - "SELECT id FROM workshop_sessions WHERE join_code = $1", - strings.ToUpper(code), - ).Scan(&id) - - if err == pgx.ErrNoRows { - return nil, nil - } - if err != nil { - return nil, err - } - - return s.GetSession(ctx, id) -} - -// ListSessions lists sessions for a tenant with optional filters -func (s *Store) ListSessions(ctx context.Context, tenantID uuid.UUID, filters *SessionFilters) ([]Session, error) { - query := ` - SELECT - id, tenant_id, namespace_id, - title, description, session_type, status, - wizard_schema, current_step, total_steps, - assessment_id, roadmap_id, portfolio_id, - scheduled_start, scheduled_end, actual_start, actual_end, - join_code, require_auth, allow_anonymous, - settings, - created_at, updated_at, created_by - FROM workshop_sessions 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.SessionType != "" { - query += fmt.Sprintf(" AND session_type = $%d", argIdx) - args = append(args, filters.SessionType) - argIdx++ - } - if filters.AssessmentID != nil { - query += fmt.Sprintf(" AND assessment_id = $%d", argIdx) - args = append(args, *filters.AssessmentID) - argIdx++ - } - if filters.CreatedBy != nil { - query += fmt.Sprintf(" AND created_by = $%d", argIdx) - args = append(args, *filters.CreatedBy) - argIdx++ - } - } - - query += " ORDER BY created_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 sessions []Session - for rows.Next() { - var session Session - var status string - var settings []byte - - err := rows.Scan( - &session.ID, &session.TenantID, &session.NamespaceID, - &session.Title, &session.Description, &session.SessionType, &status, - &session.WizardSchema, &session.CurrentStep, &session.TotalSteps, - &session.AssessmentID, &session.RoadmapID, &session.PortfolioID, - &session.ScheduledStart, &session.ScheduledEnd, &session.ActualStart, &session.ActualEnd, - &session.JoinCode, &session.RequireAuth, &session.AllowAnonymous, - &settings, - &session.CreatedAt, &session.UpdatedAt, &session.CreatedBy, - ) - if err != nil { - return nil, err - } - - session.Status = SessionStatus(status) - json.Unmarshal(settings, &session.Settings) - - sessions = append(sessions, session) - } - - return sessions, nil -} - -// UpdateSession updates a session -func (s *Store) UpdateSession(ctx context.Context, session *Session) error { - session.UpdatedAt = time.Now().UTC() - - settings, _ := json.Marshal(session.Settings) - - _, err := s.pool.Exec(ctx, ` - UPDATE workshop_sessions SET - title = $2, description = $3, status = $4, - wizard_schema = $5, current_step = $6, total_steps = $7, - scheduled_start = $8, scheduled_end = $9, - actual_start = $10, actual_end = $11, - require_auth = $12, allow_anonymous = $13, - settings = $14, - updated_at = $15 - WHERE id = $1 - `, - session.ID, session.Title, session.Description, string(session.Status), - session.WizardSchema, session.CurrentStep, session.TotalSteps, - session.ScheduledStart, session.ScheduledEnd, - session.ActualStart, session.ActualEnd, - session.RequireAuth, session.AllowAnonymous, - settings, - session.UpdatedAt, - ) - - return err -} - -// UpdateSessionStatus updates only the session status -func (s *Store) UpdateSessionStatus(ctx context.Context, id uuid.UUID, status SessionStatus) error { - now := time.Now().UTC() - - query := "UPDATE workshop_sessions SET status = $2, updated_at = $3" - - if status == SessionStatusActive { - query += ", actual_start = COALESCE(actual_start, $3)" - } else if status == SessionStatusCompleted || status == SessionStatusCancelled { - query += ", actual_end = $3" - } - - query += " WHERE id = $1" - - _, err := s.pool.Exec(ctx, query, id, string(status), now) - return err -} - -// AdvanceStep advances the session to the next step -func (s *Store) AdvanceStep(ctx context.Context, id uuid.UUID) error { - _, err := s.pool.Exec(ctx, ` - UPDATE workshop_sessions SET - current_step = current_step + 1, - updated_at = NOW() - WHERE id = $1 AND current_step < total_steps - `, id) - return err -} - -// DeleteSession deletes a session and its related data -func (s *Store) DeleteSession(ctx context.Context, id uuid.UUID) error { - // Delete in order: comments, responses, step_progress, participants, session - _, err := s.pool.Exec(ctx, "DELETE FROM workshop_comments WHERE session_id = $1", id) - if err != nil { - return err - } - _, err = s.pool.Exec(ctx, "DELETE FROM workshop_responses WHERE session_id = $1", id) - if err != nil { - return err - } - _, err = s.pool.Exec(ctx, "DELETE FROM workshop_step_progress WHERE session_id = $1", id) - if err != nil { - return err - } - _, err = s.pool.Exec(ctx, "DELETE FROM workshop_participants WHERE session_id = $1", id) - if err != nil { - return err - } - _, err = s.pool.Exec(ctx, "DELETE FROM workshop_sessions WHERE id = $1", id) - return err -} - -// ============================================================================ -// Participant Operations -// ============================================================================ - -// AddParticipant adds a participant to a session -func (s *Store) AddParticipant(ctx context.Context, p *Participant) error { - p.ID = uuid.New() - p.JoinedAt = time.Now().UTC() - p.IsActive = true - now := p.JoinedAt - p.LastActiveAt = &now - - _, err := s.pool.Exec(ctx, ` - INSERT INTO workshop_participants ( - id, session_id, user_id, - name, email, role, department, - is_active, last_active_at, joined_at, left_at, - can_edit, can_comment, can_approve - ) VALUES ( - $1, $2, $3, - $4, $5, $6, $7, - $8, $9, $10, $11, - $12, $13, $14 - ) - `, - p.ID, p.SessionID, p.UserID, - p.Name, p.Email, string(p.Role), p.Department, - p.IsActive, p.LastActiveAt, p.JoinedAt, p.LeftAt, - p.CanEdit, p.CanComment, p.CanApprove, - ) - - return err -} - -// GetParticipant retrieves a participant by ID -func (s *Store) GetParticipant(ctx context.Context, id uuid.UUID) (*Participant, error) { - var p Participant - var role string - - err := s.pool.QueryRow(ctx, ` - SELECT - id, session_id, user_id, - name, email, role, department, - is_active, last_active_at, joined_at, left_at, - can_edit, can_comment, can_approve - FROM workshop_participants WHERE id = $1 - `, id).Scan( - &p.ID, &p.SessionID, &p.UserID, - &p.Name, &p.Email, &role, &p.Department, - &p.IsActive, &p.LastActiveAt, &p.JoinedAt, &p.LeftAt, - &p.CanEdit, &p.CanComment, &p.CanApprove, - ) - - if err == pgx.ErrNoRows { - return nil, nil - } - if err != nil { - return nil, err - } - - p.Role = ParticipantRole(role) - return &p, nil -} - -// ListParticipants lists participants for a session -func (s *Store) ListParticipants(ctx context.Context, sessionID uuid.UUID) ([]Participant, error) { - rows, err := s.pool.Query(ctx, ` - SELECT - id, session_id, user_id, - name, email, role, department, - is_active, last_active_at, joined_at, left_at, - can_edit, can_comment, can_approve - FROM workshop_participants WHERE session_id = $1 - ORDER BY joined_at ASC - `, sessionID) - if err != nil { - return nil, err - } - defer rows.Close() - - var participants []Participant - for rows.Next() { - var p Participant - var role string - - err := rows.Scan( - &p.ID, &p.SessionID, &p.UserID, - &p.Name, &p.Email, &role, &p.Department, - &p.IsActive, &p.LastActiveAt, &p.JoinedAt, &p.LeftAt, - &p.CanEdit, &p.CanComment, &p.CanApprove, - ) - if err != nil { - return nil, err - } - - p.Role = ParticipantRole(role) - participants = append(participants, p) - } - - return participants, nil -} - -// UpdateParticipantActivity updates the last active timestamp -func (s *Store) UpdateParticipantActivity(ctx context.Context, id uuid.UUID) error { - _, err := s.pool.Exec(ctx, ` - UPDATE workshop_participants SET - last_active_at = NOW(), - is_active = true - WHERE id = $1 - `, id) - return err -} - -// LeaveSession marks a participant as having left -func (s *Store) LeaveSession(ctx context.Context, participantID uuid.UUID) error { - now := time.Now().UTC() - _, err := s.pool.Exec(ctx, ` - UPDATE workshop_participants SET - is_active = false, - left_at = $2 - WHERE id = $1 - `, participantID, now) - return err -} - -// UpdateParticipant updates a participant's information -func (s *Store) UpdateParticipant(ctx context.Context, p *Participant) error { - _, err := s.pool.Exec(ctx, ` - UPDATE workshop_participants SET - name = $2, - email = $3, - role = $4, - department = $5, - can_edit = $6, - can_comment = $7, - can_approve = $8 - WHERE id = $1 - `, - p.ID, - p.Name, p.Email, string(p.Role), p.Department, - p.CanEdit, p.CanComment, p.CanApprove, - ) - return err -} - -// ============================================================================ -// Comment Operations -// ============================================================================ - -// AddComment adds a comment to a session -func (s *Store) AddComment(ctx context.Context, c *Comment) error { - c.ID = uuid.New() - c.CreatedAt = time.Now().UTC() - c.UpdatedAt = c.CreatedAt - - _, err := s.pool.Exec(ctx, ` - INSERT INTO workshop_comments ( - id, session_id, participant_id, - step_number, field_id, response_id, - text, is_resolved, - created_at, updated_at - ) VALUES ( - $1, $2, $3, $4, $5, $6, $7, $8, $9, $10 - ) - `, - c.ID, c.SessionID, c.ParticipantID, - c.StepNumber, c.FieldID, c.ResponseID, - c.Text, c.IsResolved, - c.CreatedAt, c.UpdatedAt, - ) - - return err -} - -// GetComments retrieves comments for a session -func (s *Store) GetComments(ctx context.Context, sessionID uuid.UUID, stepNumber *int) ([]Comment, error) { - query := ` - SELECT - id, session_id, participant_id, - step_number, field_id, response_id, - text, is_resolved, - created_at, updated_at - FROM workshop_comments WHERE session_id = $1` - - args := []interface{}{sessionID} - if stepNumber != nil { - query += " AND step_number = $2" - args = append(args, *stepNumber) - } - - query += " ORDER BY created_at ASC" - - rows, err := s.pool.Query(ctx, query, args...) - if err != nil { - return nil, err - } - defer rows.Close() - - var comments []Comment - for rows.Next() { - var c Comment - err := rows.Scan( - &c.ID, &c.SessionID, &c.ParticipantID, - &c.StepNumber, &c.FieldID, &c.ResponseID, - &c.Text, &c.IsResolved, - &c.CreatedAt, &c.UpdatedAt, - ) - if err != nil { - return nil, err - } - comments = append(comments, c) - } - - return comments, nil -} - -// ============================================================================ -// Response Operations -// ============================================================================ - -// SaveResponse creates or updates a response -func (s *Store) SaveResponse(ctx context.Context, r *Response) error { - r.UpdatedAt = time.Now().UTC() - - valueJSON, _ := json.Marshal(r.Value) - - // Upsert based on session_id, participant_id, field_id - _, err := s.pool.Exec(ctx, ` - INSERT INTO workshop_responses ( - id, session_id, participant_id, - step_number, field_id, - value, value_type, status, - created_at, updated_at - ) VALUES ( - $1, $2, $3, $4, $5, $6, $7, $8, $9, $10 - ) - ON CONFLICT (session_id, participant_id, field_id) DO UPDATE SET - value = $6, - value_type = $7, - status = $8, - updated_at = $10 - `, - uuid.New(), r.SessionID, r.ParticipantID, - r.StepNumber, r.FieldID, - valueJSON, r.ValueType, string(r.Status), - r.UpdatedAt, r.UpdatedAt, - ) - - return err -} - -// GetResponses retrieves responses for a session -func (s *Store) GetResponses(ctx context.Context, sessionID uuid.UUID, stepNumber *int) ([]Response, error) { - query := ` - SELECT - id, session_id, participant_id, - step_number, field_id, - value, value_type, status, - reviewed_by, reviewed_at, review_notes, - created_at, updated_at - FROM workshop_responses WHERE session_id = $1` - - args := []interface{}{sessionID} - if stepNumber != nil { - query += " AND step_number = $2" - args = append(args, *stepNumber) - } - - query += " ORDER BY step_number ASC, field_id ASC" - - rows, err := s.pool.Query(ctx, query, args...) - if err != nil { - return nil, err - } - defer rows.Close() - - var responses []Response - for rows.Next() { - var r Response - var status string - var valueJSON []byte - - err := rows.Scan( - &r.ID, &r.SessionID, &r.ParticipantID, - &r.StepNumber, &r.FieldID, - &valueJSON, &r.ValueType, &status, - &r.ReviewedBy, &r.ReviewedAt, &r.ReviewNotes, - &r.CreatedAt, &r.UpdatedAt, - ) - if err != nil { - return nil, err - } - - r.Status = ResponseStatus(status) - json.Unmarshal(valueJSON, &r.Value) - - responses = append(responses, r) - } - - return responses, nil -} - -// ============================================================================ -// Step Progress Operations -// ============================================================================ - -// UpdateStepProgress updates the progress for a step -func (s *Store) UpdateStepProgress(ctx context.Context, sessionID uuid.UUID, stepNumber int, status string, progress int) error { - now := time.Now().UTC() - - _, err := s.pool.Exec(ctx, ` - INSERT INTO workshop_step_progress ( - id, session_id, step_number, - status, progress, started_at - ) VALUES ( - $1, $2, $3, $4, $5, $6 - ) - ON CONFLICT (session_id, step_number) DO UPDATE SET - status = $4, - progress = $5, - completed_at = CASE WHEN $4 = 'completed' THEN $6 ELSE NULL END - `, uuid.New(), sessionID, stepNumber, status, progress, now) - - return err -} - -// GetStepProgress retrieves step progress for a session -func (s *Store) GetStepProgress(ctx context.Context, sessionID uuid.UUID) ([]StepProgress, error) { - rows, err := s.pool.Query(ctx, ` - SELECT - id, session_id, step_number, - status, progress, - started_at, completed_at, notes - FROM workshop_step_progress WHERE session_id = $1 - ORDER BY step_number ASC - `, sessionID) - if err != nil { - return nil, err - } - defer rows.Close() - - var progress []StepProgress - for rows.Next() { - var sp StepProgress - err := rows.Scan( - &sp.ID, &sp.SessionID, &sp.StepNumber, - &sp.Status, &sp.Progress, - &sp.StartedAt, &sp.CompletedAt, &sp.Notes, - ) - if err != nil { - return nil, err - } - progress = append(progress, sp) - } - - return progress, nil -} - -// ============================================================================ -// Statistics -// ============================================================================ - -// GetSessionStats returns statistics for a session -func (s *Store) GetSessionStats(ctx context.Context, sessionID uuid.UUID) (*SessionStats, error) { - stats := &SessionStats{ - ResponsesByStep: make(map[int]int), - ResponsesByField: make(map[string]int), - } - - // Participant counts - s.pool.QueryRow(ctx, - "SELECT COUNT(*) FROM workshop_participants WHERE session_id = $1", - sessionID).Scan(&stats.ParticipantCount) - - s.pool.QueryRow(ctx, - "SELECT COUNT(*) FROM workshop_participants WHERE session_id = $1 AND is_active = true", - sessionID).Scan(&stats.ActiveParticipants) - - // Response count - s.pool.QueryRow(ctx, - "SELECT COUNT(*) FROM workshop_responses WHERE session_id = $1", - sessionID).Scan(&stats.ResponseCount) - - // Comment count - s.pool.QueryRow(ctx, - "SELECT COUNT(*) FROM workshop_comments WHERE session_id = $1", - sessionID).Scan(&stats.CommentCount) - - // Step progress - s.pool.QueryRow(ctx, - "SELECT COUNT(*) FROM workshop_step_progress WHERE session_id = $1 AND status = 'completed'", - sessionID).Scan(&stats.CompletedSteps) - - s.pool.QueryRow(ctx, - "SELECT total_steps FROM workshop_sessions WHERE id = $1", - sessionID).Scan(&stats.TotalSteps) - - // Responses by step - rows, _ := s.pool.Query(ctx, - "SELECT step_number, COUNT(*) FROM workshop_responses WHERE session_id = $1 GROUP BY step_number", - sessionID) - if rows != nil { - defer rows.Close() - for rows.Next() { - var step, count int - rows.Scan(&step, &count) - stats.ResponsesByStep[step] = count - } - } - - // Responses by field - rows, _ = s.pool.Query(ctx, - "SELECT field_id, COUNT(*) FROM workshop_responses WHERE session_id = $1 GROUP BY field_id", - sessionID) - if rows != nil { - defer rows.Close() - for rows.Next() { - var field string - var count int - rows.Scan(&field, &count) - stats.ResponsesByField[field] = count - } - } - - // Average progress - if stats.TotalSteps > 0 { - stats.AverageProgress = (stats.CompletedSteps * 100) / stats.TotalSteps - } - - return stats, nil -} - -// GetSessionSummary returns a complete session summary -func (s *Store) GetSessionSummary(ctx context.Context, sessionID uuid.UUID) (*SessionSummary, error) { - session, err := s.GetSession(ctx, sessionID) - if err != nil || session == nil { - return nil, err - } - - participants, err := s.ListParticipants(ctx, sessionID) - if err != nil { - return nil, err - } - - stepProgress, err := s.GetStepProgress(ctx, sessionID) - if err != nil { - return nil, err - } - - var responseCount int - s.pool.QueryRow(ctx, - "SELECT COUNT(*) FROM workshop_responses WHERE session_id = $1", - sessionID).Scan(&responseCount) - - completedSteps := 0 - for _, sp := range stepProgress { - if sp.Status == "completed" { - completedSteps++ - } - } - - progress := 0 - if session.TotalSteps > 0 { - progress = (completedSteps * 100) / session.TotalSteps - } - - return &SessionSummary{ - Session: session, - Participants: participants, - StepProgress: stepProgress, - TotalResponses: responseCount, - CompletedSteps: completedSteps, - OverallProgress: progress, - }, nil -} - -// ============================================================================ -// Helpers -// ============================================================================ - -// generateJoinCode generates a random 6-character join code -func generateJoinCode() string { - b := make([]byte, 4) - rand.Read(b) - code := base32.StdEncoding.EncodeToString(b)[:6] - return strings.ToUpper(code) -} diff --git a/ai-compliance-sdk/internal/workshop/store_participants.go b/ai-compliance-sdk/internal/workshop/store_participants.go new file mode 100644 index 0000000..bce368e --- /dev/null +++ b/ai-compliance-sdk/internal/workshop/store_participants.go @@ -0,0 +1,225 @@ +package workshop + +import ( + "context" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" +) + +// ============================================================================ +// Participant Operations +// ============================================================================ + +// AddParticipant adds a participant to a session +func (s *Store) AddParticipant(ctx context.Context, p *Participant) error { + p.ID = uuid.New() + p.JoinedAt = time.Now().UTC() + p.IsActive = true + now := p.JoinedAt + p.LastActiveAt = &now + + _, err := s.pool.Exec(ctx, ` + INSERT INTO workshop_participants ( + id, session_id, user_id, + name, email, role, department, + is_active, last_active_at, joined_at, left_at, + can_edit, can_comment, can_approve + ) VALUES ( + $1, $2, $3, + $4, $5, $6, $7, + $8, $9, $10, $11, + $12, $13, $14 + ) + `, + p.ID, p.SessionID, p.UserID, + p.Name, p.Email, string(p.Role), p.Department, + p.IsActive, p.LastActiveAt, p.JoinedAt, p.LeftAt, + p.CanEdit, p.CanComment, p.CanApprove, + ) + + return err +} + +// GetParticipant retrieves a participant by ID +func (s *Store) GetParticipant(ctx context.Context, id uuid.UUID) (*Participant, error) { + var p Participant + var role string + + err := s.pool.QueryRow(ctx, ` + SELECT + id, session_id, user_id, + name, email, role, department, + is_active, last_active_at, joined_at, left_at, + can_edit, can_comment, can_approve + FROM workshop_participants WHERE id = $1 + `, id).Scan( + &p.ID, &p.SessionID, &p.UserID, + &p.Name, &p.Email, &role, &p.Department, + &p.IsActive, &p.LastActiveAt, &p.JoinedAt, &p.LeftAt, + &p.CanEdit, &p.CanComment, &p.CanApprove, + ) + + if err == pgx.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, err + } + + p.Role = ParticipantRole(role) + return &p, nil +} + +// ListParticipants lists participants for a session +func (s *Store) ListParticipants(ctx context.Context, sessionID uuid.UUID) ([]Participant, error) { + rows, err := s.pool.Query(ctx, ` + SELECT + id, session_id, user_id, + name, email, role, department, + is_active, last_active_at, joined_at, left_at, + can_edit, can_comment, can_approve + FROM workshop_participants WHERE session_id = $1 + ORDER BY joined_at ASC + `, sessionID) + if err != nil { + return nil, err + } + defer rows.Close() + + var participants []Participant + for rows.Next() { + var p Participant + var role string + + err := rows.Scan( + &p.ID, &p.SessionID, &p.UserID, + &p.Name, &p.Email, &role, &p.Department, + &p.IsActive, &p.LastActiveAt, &p.JoinedAt, &p.LeftAt, + &p.CanEdit, &p.CanComment, &p.CanApprove, + ) + if err != nil { + return nil, err + } + + p.Role = ParticipantRole(role) + participants = append(participants, p) + } + + return participants, nil +} + +// UpdateParticipantActivity updates the last active timestamp +func (s *Store) UpdateParticipantActivity(ctx context.Context, id uuid.UUID) error { + _, err := s.pool.Exec(ctx, ` + UPDATE workshop_participants SET + last_active_at = NOW(), + is_active = true + WHERE id = $1 + `, id) + return err +} + +// LeaveSession marks a participant as having left +func (s *Store) LeaveSession(ctx context.Context, participantID uuid.UUID) error { + now := time.Now().UTC() + _, err := s.pool.Exec(ctx, ` + UPDATE workshop_participants SET + is_active = false, + left_at = $2 + WHERE id = $1 + `, participantID, now) + return err +} + +// UpdateParticipant updates a participant's information +func (s *Store) UpdateParticipant(ctx context.Context, p *Participant) error { + _, err := s.pool.Exec(ctx, ` + UPDATE workshop_participants SET + name = $2, + email = $3, + role = $4, + department = $5, + can_edit = $6, + can_comment = $7, + can_approve = $8 + WHERE id = $1 + `, + p.ID, + p.Name, p.Email, string(p.Role), p.Department, + p.CanEdit, p.CanComment, p.CanApprove, + ) + return err +} + +// ============================================================================ +// Comment Operations +// ============================================================================ + +// AddComment adds a comment to a session +func (s *Store) AddComment(ctx context.Context, c *Comment) error { + c.ID = uuid.New() + c.CreatedAt = time.Now().UTC() + c.UpdatedAt = c.CreatedAt + + _, err := s.pool.Exec(ctx, ` + INSERT INTO workshop_comments ( + id, session_id, participant_id, + step_number, field_id, response_id, + text, is_resolved, + created_at, updated_at + ) VALUES ( + $1, $2, $3, $4, $5, $6, $7, $8, $9, $10 + ) + `, + c.ID, c.SessionID, c.ParticipantID, + c.StepNumber, c.FieldID, c.ResponseID, + c.Text, c.IsResolved, + c.CreatedAt, c.UpdatedAt, + ) + + return err +} + +// GetComments retrieves comments for a session +func (s *Store) GetComments(ctx context.Context, sessionID uuid.UUID, stepNumber *int) ([]Comment, error) { + query := ` + SELECT + id, session_id, participant_id, + step_number, field_id, response_id, + text, is_resolved, + created_at, updated_at + FROM workshop_comments WHERE session_id = $1` + + args := []interface{}{sessionID} + if stepNumber != nil { + query += " AND step_number = $2" + args = append(args, *stepNumber) + } + + query += " ORDER BY created_at ASC" + + rows, err := s.pool.Query(ctx, query, args...) + if err != nil { + return nil, err + } + defer rows.Close() + + var comments []Comment + for rows.Next() { + var c Comment + err := rows.Scan( + &c.ID, &c.SessionID, &c.ParticipantID, + &c.StepNumber, &c.FieldID, &c.ResponseID, + &c.Text, &c.IsResolved, + &c.CreatedAt, &c.UpdatedAt, + ) + if err != nil { + return nil, err + } + comments = append(comments, c) + } + + return comments, nil +} diff --git a/ai-compliance-sdk/internal/workshop/store_responses.go b/ai-compliance-sdk/internal/workshop/store_responses.go new file mode 100644 index 0000000..6d6911d --- /dev/null +++ b/ai-compliance-sdk/internal/workshop/store_responses.go @@ -0,0 +1,269 @@ +package workshop + +import ( + "context" + "encoding/json" + "time" + + "github.com/google/uuid" +) + +// ============================================================================ +// Response Operations +// ============================================================================ + +// SaveResponse creates or updates a response +func (s *Store) SaveResponse(ctx context.Context, r *Response) error { + r.UpdatedAt = time.Now().UTC() + + valueJSON, _ := json.Marshal(r.Value) + + // Upsert based on session_id, participant_id, field_id + _, err := s.pool.Exec(ctx, ` + INSERT INTO workshop_responses ( + id, session_id, participant_id, + step_number, field_id, + value, value_type, status, + created_at, updated_at + ) VALUES ( + $1, $2, $3, $4, $5, $6, $7, $8, $9, $10 + ) + ON CONFLICT (session_id, participant_id, field_id) DO UPDATE SET + value = $6, + value_type = $7, + status = $8, + updated_at = $10 + `, + uuid.New(), r.SessionID, r.ParticipantID, + r.StepNumber, r.FieldID, + valueJSON, r.ValueType, string(r.Status), + r.UpdatedAt, r.UpdatedAt, + ) + + return err +} + +// GetResponses retrieves responses for a session +func (s *Store) GetResponses(ctx context.Context, sessionID uuid.UUID, stepNumber *int) ([]Response, error) { + query := ` + SELECT + id, session_id, participant_id, + step_number, field_id, + value, value_type, status, + reviewed_by, reviewed_at, review_notes, + created_at, updated_at + FROM workshop_responses WHERE session_id = $1` + + args := []interface{}{sessionID} + if stepNumber != nil { + query += " AND step_number = $2" + args = append(args, *stepNumber) + } + + query += " ORDER BY step_number ASC, field_id ASC" + + rows, err := s.pool.Query(ctx, query, args...) + if err != nil { + return nil, err + } + defer rows.Close() + + var responses []Response + for rows.Next() { + var r Response + var status string + var valueJSON []byte + + err := rows.Scan( + &r.ID, &r.SessionID, &r.ParticipantID, + &r.StepNumber, &r.FieldID, + &valueJSON, &r.ValueType, &status, + &r.ReviewedBy, &r.ReviewedAt, &r.ReviewNotes, + &r.CreatedAt, &r.UpdatedAt, + ) + if err != nil { + return nil, err + } + + r.Status = ResponseStatus(status) + json.Unmarshal(valueJSON, &r.Value) + + responses = append(responses, r) + } + + return responses, nil +} + +// ============================================================================ +// Step Progress Operations +// ============================================================================ + +// UpdateStepProgress updates the progress for a step +func (s *Store) UpdateStepProgress(ctx context.Context, sessionID uuid.UUID, stepNumber int, status string, progress int) error { + now := time.Now().UTC() + + _, err := s.pool.Exec(ctx, ` + INSERT INTO workshop_step_progress ( + id, session_id, step_number, + status, progress, started_at + ) VALUES ( + $1, $2, $3, $4, $5, $6 + ) + ON CONFLICT (session_id, step_number) DO UPDATE SET + status = $4, + progress = $5, + completed_at = CASE WHEN $4 = 'completed' THEN $6 ELSE NULL END + `, uuid.New(), sessionID, stepNumber, status, progress, now) + + return err +} + +// GetStepProgress retrieves step progress for a session +func (s *Store) GetStepProgress(ctx context.Context, sessionID uuid.UUID) ([]StepProgress, error) { + rows, err := s.pool.Query(ctx, ` + SELECT + id, session_id, step_number, + status, progress, + started_at, completed_at, notes + FROM workshop_step_progress WHERE session_id = $1 + ORDER BY step_number ASC + `, sessionID) + if err != nil { + return nil, err + } + defer rows.Close() + + var progress []StepProgress + for rows.Next() { + var sp StepProgress + err := rows.Scan( + &sp.ID, &sp.SessionID, &sp.StepNumber, + &sp.Status, &sp.Progress, + &sp.StartedAt, &sp.CompletedAt, &sp.Notes, + ) + if err != nil { + return nil, err + } + progress = append(progress, sp) + } + + return progress, nil +} + +// ============================================================================ +// Statistics +// ============================================================================ + +// GetSessionStats returns statistics for a session +func (s *Store) GetSessionStats(ctx context.Context, sessionID uuid.UUID) (*SessionStats, error) { + stats := &SessionStats{ + ResponsesByStep: make(map[int]int), + ResponsesByField: make(map[string]int), + } + + // Participant counts + s.pool.QueryRow(ctx, + "SELECT COUNT(*) FROM workshop_participants WHERE session_id = $1", + sessionID).Scan(&stats.ParticipantCount) + + s.pool.QueryRow(ctx, + "SELECT COUNT(*) FROM workshop_participants WHERE session_id = $1 AND is_active = true", + sessionID).Scan(&stats.ActiveParticipants) + + // Response count + s.pool.QueryRow(ctx, + "SELECT COUNT(*) FROM workshop_responses WHERE session_id = $1", + sessionID).Scan(&stats.ResponseCount) + + // Comment count + s.pool.QueryRow(ctx, + "SELECT COUNT(*) FROM workshop_comments WHERE session_id = $1", + sessionID).Scan(&stats.CommentCount) + + // Step progress + s.pool.QueryRow(ctx, + "SELECT COUNT(*) FROM workshop_step_progress WHERE session_id = $1 AND status = 'completed'", + sessionID).Scan(&stats.CompletedSteps) + + s.pool.QueryRow(ctx, + "SELECT total_steps FROM workshop_sessions WHERE id = $1", + sessionID).Scan(&stats.TotalSteps) + + // Responses by step + rows, _ := s.pool.Query(ctx, + "SELECT step_number, COUNT(*) FROM workshop_responses WHERE session_id = $1 GROUP BY step_number", + sessionID) + if rows != nil { + defer rows.Close() + for rows.Next() { + var step, count int + rows.Scan(&step, &count) + stats.ResponsesByStep[step] = count + } + } + + // Responses by field + rows, _ = s.pool.Query(ctx, + "SELECT field_id, COUNT(*) FROM workshop_responses WHERE session_id = $1 GROUP BY field_id", + sessionID) + if rows != nil { + defer rows.Close() + for rows.Next() { + var field string + var count int + rows.Scan(&field, &count) + stats.ResponsesByField[field] = count + } + } + + // Average progress + if stats.TotalSteps > 0 { + stats.AverageProgress = (stats.CompletedSteps * 100) / stats.TotalSteps + } + + return stats, nil +} + +// GetSessionSummary returns a complete session summary +func (s *Store) GetSessionSummary(ctx context.Context, sessionID uuid.UUID) (*SessionSummary, error) { + session, err := s.GetSession(ctx, sessionID) + if err != nil || session == nil { + return nil, err + } + + participants, err := s.ListParticipants(ctx, sessionID) + if err != nil { + return nil, err + } + + stepProgress, err := s.GetStepProgress(ctx, sessionID) + if err != nil { + return nil, err + } + + var responseCount int + s.pool.QueryRow(ctx, + "SELECT COUNT(*) FROM workshop_responses WHERE session_id = $1", + sessionID).Scan(&responseCount) + + completedSteps := 0 + for _, sp := range stepProgress { + if sp.Status == "completed" { + completedSteps++ + } + } + + progress := 0 + if session.TotalSteps > 0 { + progress = (completedSteps * 100) / session.TotalSteps + } + + return &SessionSummary{ + Session: session, + Participants: participants, + StepProgress: stepProgress, + TotalResponses: responseCount, + CompletedSteps: completedSteps, + OverallProgress: progress, + }, nil +} diff --git a/ai-compliance-sdk/internal/workshop/store_sessions.go b/ai-compliance-sdk/internal/workshop/store_sessions.go new file mode 100644 index 0000000..736c2ed --- /dev/null +++ b/ai-compliance-sdk/internal/workshop/store_sessions.go @@ -0,0 +1,317 @@ +package workshop + +import ( + "context" + "crypto/rand" + "encoding/base32" + "encoding/json" + "fmt" + "strings" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" +) + +// Store handles workshop session data persistence +type Store struct { + pool *pgxpool.Pool +} + +// NewStore creates a new workshop store +func NewStore(pool *pgxpool.Pool) *Store { + return &Store{pool: pool} +} + +// ============================================================================ +// Session CRUD Operations +// ============================================================================ + +// CreateSession creates a new workshop session +func (s *Store) CreateSession(ctx context.Context, session *Session) error { + session.ID = uuid.New() + session.CreatedAt = time.Now().UTC() + session.UpdatedAt = session.CreatedAt + if session.Status == "" { + session.Status = SessionStatusDraft + } + if session.JoinCode == "" { + session.JoinCode = generateJoinCode() + } + + settings, _ := json.Marshal(session.Settings) + + _, err := s.pool.Exec(ctx, ` + INSERT INTO workshop_sessions ( + id, tenant_id, namespace_id, + title, description, session_type, status, + wizard_schema, current_step, total_steps, + assessment_id, roadmap_id, portfolio_id, + scheduled_start, scheduled_end, actual_start, actual_end, + join_code, require_auth, allow_anonymous, + settings, + created_at, updated_at, created_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 + ) + `, + session.ID, session.TenantID, session.NamespaceID, + session.Title, session.Description, session.SessionType, string(session.Status), + session.WizardSchema, session.CurrentStep, session.TotalSteps, + session.AssessmentID, session.RoadmapID, session.PortfolioID, + session.ScheduledStart, session.ScheduledEnd, session.ActualStart, session.ActualEnd, + session.JoinCode, session.RequireAuth, session.AllowAnonymous, + settings, + session.CreatedAt, session.UpdatedAt, session.CreatedBy, + ) + + return err +} + +// GetSession retrieves a session by ID +func (s *Store) GetSession(ctx context.Context, id uuid.UUID) (*Session, error) { + var session Session + var status string + var settings []byte + + err := s.pool.QueryRow(ctx, ` + SELECT + id, tenant_id, namespace_id, + title, description, session_type, status, + wizard_schema, current_step, total_steps, + assessment_id, roadmap_id, portfolio_id, + scheduled_start, scheduled_end, actual_start, actual_end, + join_code, require_auth, allow_anonymous, + settings, + created_at, updated_at, created_by + FROM workshop_sessions WHERE id = $1 + `, id).Scan( + &session.ID, &session.TenantID, &session.NamespaceID, + &session.Title, &session.Description, &session.SessionType, &status, + &session.WizardSchema, &session.CurrentStep, &session.TotalSteps, + &session.AssessmentID, &session.RoadmapID, &session.PortfolioID, + &session.ScheduledStart, &session.ScheduledEnd, &session.ActualStart, &session.ActualEnd, + &session.JoinCode, &session.RequireAuth, &session.AllowAnonymous, + &settings, + &session.CreatedAt, &session.UpdatedAt, &session.CreatedBy, + ) + + if err == pgx.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, err + } + + session.Status = SessionStatus(status) + json.Unmarshal(settings, &session.Settings) + + return &session, nil +} + +// GetSessionByJoinCode retrieves a session by its join code +func (s *Store) GetSessionByJoinCode(ctx context.Context, code string) (*Session, error) { + var id uuid.UUID + err := s.pool.QueryRow(ctx, + "SELECT id FROM workshop_sessions WHERE join_code = $1", + strings.ToUpper(code), + ).Scan(&id) + + if err == pgx.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, err + } + + return s.GetSession(ctx, id) +} + +// ListSessions lists sessions for a tenant with optional filters +func (s *Store) ListSessions(ctx context.Context, tenantID uuid.UUID, filters *SessionFilters) ([]Session, error) { + query := ` + SELECT + id, tenant_id, namespace_id, + title, description, session_type, status, + wizard_schema, current_step, total_steps, + assessment_id, roadmap_id, portfolio_id, + scheduled_start, scheduled_end, actual_start, actual_end, + join_code, require_auth, allow_anonymous, + settings, + created_at, updated_at, created_by + FROM workshop_sessions 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.SessionType != "" { + query += fmt.Sprintf(" AND session_type = $%d", argIdx) + args = append(args, filters.SessionType) + argIdx++ + } + if filters.AssessmentID != nil { + query += fmt.Sprintf(" AND assessment_id = $%d", argIdx) + args = append(args, *filters.AssessmentID) + argIdx++ + } + if filters.CreatedBy != nil { + query += fmt.Sprintf(" AND created_by = $%d", argIdx) + args = append(args, *filters.CreatedBy) + argIdx++ + } + } + + query += " ORDER BY created_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 sessions []Session + for rows.Next() { + var session Session + var status string + var settings []byte + + err := rows.Scan( + &session.ID, &session.TenantID, &session.NamespaceID, + &session.Title, &session.Description, &session.SessionType, &status, + &session.WizardSchema, &session.CurrentStep, &session.TotalSteps, + &session.AssessmentID, &session.RoadmapID, &session.PortfolioID, + &session.ScheduledStart, &session.ScheduledEnd, &session.ActualStart, &session.ActualEnd, + &session.JoinCode, &session.RequireAuth, &session.AllowAnonymous, + &settings, + &session.CreatedAt, &session.UpdatedAt, &session.CreatedBy, + ) + if err != nil { + return nil, err + } + + session.Status = SessionStatus(status) + json.Unmarshal(settings, &session.Settings) + + sessions = append(sessions, session) + } + + return sessions, nil +} + +// UpdateSession updates a session +func (s *Store) UpdateSession(ctx context.Context, session *Session) error { + session.UpdatedAt = time.Now().UTC() + + settings, _ := json.Marshal(session.Settings) + + _, err := s.pool.Exec(ctx, ` + UPDATE workshop_sessions SET + title = $2, description = $3, status = $4, + wizard_schema = $5, current_step = $6, total_steps = $7, + scheduled_start = $8, scheduled_end = $9, + actual_start = $10, actual_end = $11, + require_auth = $12, allow_anonymous = $13, + settings = $14, + updated_at = $15 + WHERE id = $1 + `, + session.ID, session.Title, session.Description, string(session.Status), + session.WizardSchema, session.CurrentStep, session.TotalSteps, + session.ScheduledStart, session.ScheduledEnd, + session.ActualStart, session.ActualEnd, + session.RequireAuth, session.AllowAnonymous, + settings, + session.UpdatedAt, + ) + + return err +} + +// UpdateSessionStatus updates only the session status +func (s *Store) UpdateSessionStatus(ctx context.Context, id uuid.UUID, status SessionStatus) error { + now := time.Now().UTC() + + query := "UPDATE workshop_sessions SET status = $2, updated_at = $3" + + if status == SessionStatusActive { + query += ", actual_start = COALESCE(actual_start, $3)" + } else if status == SessionStatusCompleted || status == SessionStatusCancelled { + query += ", actual_end = $3" + } + + query += " WHERE id = $1" + + _, err := s.pool.Exec(ctx, query, id, string(status), now) + return err +} + +// AdvanceStep advances the session to the next step +func (s *Store) AdvanceStep(ctx context.Context, id uuid.UUID) error { + _, err := s.pool.Exec(ctx, ` + UPDATE workshop_sessions SET + current_step = current_step + 1, + updated_at = NOW() + WHERE id = $1 AND current_step < total_steps + `, id) + return err +} + +// DeleteSession deletes a session and its related data +func (s *Store) DeleteSession(ctx context.Context, id uuid.UUID) error { + // Delete in order: comments, responses, step_progress, participants, session + _, err := s.pool.Exec(ctx, "DELETE FROM workshop_comments WHERE session_id = $1", id) + if err != nil { + return err + } + _, err = s.pool.Exec(ctx, "DELETE FROM workshop_responses WHERE session_id = $1", id) + if err != nil { + return err + } + _, err = s.pool.Exec(ctx, "DELETE FROM workshop_step_progress WHERE session_id = $1", id) + if err != nil { + return err + } + _, err = s.pool.Exec(ctx, "DELETE FROM workshop_participants WHERE session_id = $1", id) + if err != nil { + return err + } + _, err = s.pool.Exec(ctx, "DELETE FROM workshop_sessions WHERE id = $1", id) + return err +} + +// ============================================================================ +// Helpers +// ============================================================================ + +// generateJoinCode generates a random 6-character join code +func generateJoinCode() string { + b := make([]byte, 4) + rand.Read(b) + code := base32.StdEncoding.EncodeToString(b)[:6] + return strings.ToUpper(code) +}