refactor(go): split portfolio, workshop, training/models, roadmap stores
portfolio/store.go (818 LOC) → store_portfolio.go, store_items.go, store_metrics.go workshop/store.go (793 LOC) → store_sessions.go, store_participants.go, store_responses.go training/models.go (757 LOC) → models_enums.go, models_core.go, models_api.go, models_blocks.go roadmap/store.go (757 LOC) → store_roadmap.go, store_items.go, store_import.go All files under 350 LOC. Zero behavior changes, same package declarations. go vet passes on all five packages. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
}
|
||||
281
ai-compliance-sdk/internal/portfolio/store_items.go
Normal file
281
ai-compliance-sdk/internal/portfolio/store_items.go
Normal file
@@ -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
|
||||
}
|
||||
311
ai-compliance-sdk/internal/portfolio/store_metrics.go
Normal file
311
ai-compliance-sdk/internal/portfolio/store_metrics.go
Normal file
@@ -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
|
||||
}
|
||||
238
ai-compliance-sdk/internal/portfolio/store_portfolio.go
Normal file
238
ai-compliance-sdk/internal/portfolio/store_portfolio.go
Normal file
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
213
ai-compliance-sdk/internal/roadmap/store_import.go
Normal file
213
ai-compliance-sdk/internal/roadmap/store_import.go
Normal file
@@ -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
|
||||
}
|
||||
337
ai-compliance-sdk/internal/roadmap/store_items.go
Normal file
337
ai-compliance-sdk/internal/roadmap/store_items.go
Normal file
@@ -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)
|
||||
}
|
||||
227
ai-compliance-sdk/internal/roadmap/store_roadmap.go
Normal file
227
ai-compliance-sdk/internal/roadmap/store_roadmap.go
Normal file
@@ -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
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
141
ai-compliance-sdk/internal/training/models_api.go
Normal file
141
ai-compliance-sdk/internal/training/models_api.go
Normal file
@@ -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"`
|
||||
}
|
||||
193
ai-compliance-sdk/internal/training/models_blocks.go
Normal file
193
ai-compliance-sdk/internal/training/models_blocks.go
Normal file
@@ -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"`
|
||||
}
|
||||
276
ai-compliance-sdk/internal/training/models_core.go
Normal file
276
ai-compliance-sdk/internal/training/models_core.go
Normal file
@@ -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"`
|
||||
}
|
||||
162
ai-compliance-sdk/internal/training/models_enums.go
Normal file
162
ai-compliance-sdk/internal/training/models_enums.go
Normal file
@@ -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},
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
225
ai-compliance-sdk/internal/workshop/store_participants.go
Normal file
225
ai-compliance-sdk/internal/workshop/store_participants.go
Normal file
@@ -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
|
||||
}
|
||||
269
ai-compliance-sdk/internal/workshop/store_responses.go
Normal file
269
ai-compliance-sdk/internal/workshop/store_responses.go
Normal file
@@ -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
|
||||
}
|
||||
317
ai-compliance-sdk/internal/workshop/store_sessions.go
Normal file
317
ai-compliance-sdk/internal/workshop/store_sessions.go
Normal file
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user