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:
Sharang Parnerkar
2026-04-19 09:49:31 +02:00
parent c293d76e6b
commit 3fb5b94905
17 changed files with 3190 additions and 3125 deletions

View File

@@ -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
}

View 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
}

View 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
}

View 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
}

View File

@@ -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
}

View 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
}

View 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)
}

View 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
}

View File

@@ -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"`
}

View 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"`
}

View 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"`
}

View 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"`
}

View 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},
}

View File

@@ -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)
}

View 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
}

View 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
}

View 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)
}