refactor(go): split portfolio, workshop, training/models, roadmap stores
portfolio/store.go (818 LOC) → store_portfolio.go, store_items.go, store_metrics.go workshop/store.go (793 LOC) → store_sessions.go, store_participants.go, store_responses.go training/models.go (757 LOC) → models_enums.go, models_core.go, models_api.go, models_blocks.go roadmap/store.go (757 LOC) → store_roadmap.go, store_items.go, store_import.go All files under 350 LOC. Zero behavior changes, same package declarations. go vet passes on all five packages. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,818 +0,0 @@
|
|||||||
package portfolio
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/google/uuid"
|
|
||||||
"github.com/jackc/pgx/v5"
|
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Store handles portfolio data persistence
|
|
||||||
type Store struct {
|
|
||||||
pool *pgxpool.Pool
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewStore creates a new portfolio store
|
|
||||||
func NewStore(pool *pgxpool.Pool) *Store {
|
|
||||||
return &Store{pool: pool}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Portfolio CRUD Operations
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
// CreatePortfolio creates a new portfolio
|
|
||||||
func (s *Store) CreatePortfolio(ctx context.Context, p *Portfolio) error {
|
|
||||||
p.ID = uuid.New()
|
|
||||||
p.CreatedAt = time.Now().UTC()
|
|
||||||
p.UpdatedAt = p.CreatedAt
|
|
||||||
if p.Status == "" {
|
|
||||||
p.Status = PortfolioStatusDraft
|
|
||||||
}
|
|
||||||
|
|
||||||
settings, _ := json.Marshal(p.Settings)
|
|
||||||
|
|
||||||
_, err := s.pool.Exec(ctx, `
|
|
||||||
INSERT INTO portfolios (
|
|
||||||
id, tenant_id, namespace_id,
|
|
||||||
name, description, status,
|
|
||||||
department, business_unit, owner, owner_email,
|
|
||||||
total_assessments, total_roadmaps, total_workshops,
|
|
||||||
avg_risk_score, high_risk_count, conditional_count, approved_count,
|
|
||||||
compliance_score, settings,
|
|
||||||
created_at, updated_at, created_by, approved_at, approved_by
|
|
||||||
) VALUES (
|
|
||||||
$1, $2, $3,
|
|
||||||
$4, $5, $6,
|
|
||||||
$7, $8, $9, $10,
|
|
||||||
$11, $12, $13,
|
|
||||||
$14, $15, $16, $17,
|
|
||||||
$18, $19,
|
|
||||||
$20, $21, $22, $23, $24
|
|
||||||
)
|
|
||||||
`,
|
|
||||||
p.ID, p.TenantID, p.NamespaceID,
|
|
||||||
p.Name, p.Description, string(p.Status),
|
|
||||||
p.Department, p.BusinessUnit, p.Owner, p.OwnerEmail,
|
|
||||||
p.TotalAssessments, p.TotalRoadmaps, p.TotalWorkshops,
|
|
||||||
p.AvgRiskScore, p.HighRiskCount, p.ConditionalCount, p.ApprovedCount,
|
|
||||||
p.ComplianceScore, settings,
|
|
||||||
p.CreatedAt, p.UpdatedAt, p.CreatedBy, p.ApprovedAt, p.ApprovedBy,
|
|
||||||
)
|
|
||||||
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetPortfolio retrieves a portfolio by ID
|
|
||||||
func (s *Store) GetPortfolio(ctx context.Context, id uuid.UUID) (*Portfolio, error) {
|
|
||||||
var p Portfolio
|
|
||||||
var status string
|
|
||||||
var settings []byte
|
|
||||||
|
|
||||||
err := s.pool.QueryRow(ctx, `
|
|
||||||
SELECT
|
|
||||||
id, tenant_id, namespace_id,
|
|
||||||
name, description, status,
|
|
||||||
department, business_unit, owner, owner_email,
|
|
||||||
total_assessments, total_roadmaps, total_workshops,
|
|
||||||
avg_risk_score, high_risk_count, conditional_count, approved_count,
|
|
||||||
compliance_score, settings,
|
|
||||||
created_at, updated_at, created_by, approved_at, approved_by
|
|
||||||
FROM portfolios WHERE id = $1
|
|
||||||
`, id).Scan(
|
|
||||||
&p.ID, &p.TenantID, &p.NamespaceID,
|
|
||||||
&p.Name, &p.Description, &status,
|
|
||||||
&p.Department, &p.BusinessUnit, &p.Owner, &p.OwnerEmail,
|
|
||||||
&p.TotalAssessments, &p.TotalRoadmaps, &p.TotalWorkshops,
|
|
||||||
&p.AvgRiskScore, &p.HighRiskCount, &p.ConditionalCount, &p.ApprovedCount,
|
|
||||||
&p.ComplianceScore, &settings,
|
|
||||||
&p.CreatedAt, &p.UpdatedAt, &p.CreatedBy, &p.ApprovedAt, &p.ApprovedBy,
|
|
||||||
)
|
|
||||||
|
|
||||||
if err == pgx.ErrNoRows {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
p.Status = PortfolioStatus(status)
|
|
||||||
json.Unmarshal(settings, &p.Settings)
|
|
||||||
|
|
||||||
return &p, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ListPortfolios lists portfolios for a tenant with optional filters
|
|
||||||
func (s *Store) ListPortfolios(ctx context.Context, tenantID uuid.UUID, filters *PortfolioFilters) ([]Portfolio, error) {
|
|
||||||
query := `
|
|
||||||
SELECT
|
|
||||||
id, tenant_id, namespace_id,
|
|
||||||
name, description, status,
|
|
||||||
department, business_unit, owner, owner_email,
|
|
||||||
total_assessments, total_roadmaps, total_workshops,
|
|
||||||
avg_risk_score, high_risk_count, conditional_count, approved_count,
|
|
||||||
compliance_score, settings,
|
|
||||||
created_at, updated_at, created_by, approved_at, approved_by
|
|
||||||
FROM portfolios WHERE tenant_id = $1`
|
|
||||||
|
|
||||||
args := []interface{}{tenantID}
|
|
||||||
argIdx := 2
|
|
||||||
|
|
||||||
if filters != nil {
|
|
||||||
if filters.Status != "" {
|
|
||||||
query += fmt.Sprintf(" AND status = $%d", argIdx)
|
|
||||||
args = append(args, string(filters.Status))
|
|
||||||
argIdx++
|
|
||||||
}
|
|
||||||
if filters.Department != "" {
|
|
||||||
query += fmt.Sprintf(" AND department = $%d", argIdx)
|
|
||||||
args = append(args, filters.Department)
|
|
||||||
argIdx++
|
|
||||||
}
|
|
||||||
if filters.BusinessUnit != "" {
|
|
||||||
query += fmt.Sprintf(" AND business_unit = $%d", argIdx)
|
|
||||||
args = append(args, filters.BusinessUnit)
|
|
||||||
argIdx++
|
|
||||||
}
|
|
||||||
if filters.Owner != "" {
|
|
||||||
query += fmt.Sprintf(" AND owner ILIKE $%d", argIdx)
|
|
||||||
args = append(args, "%"+filters.Owner+"%")
|
|
||||||
argIdx++
|
|
||||||
}
|
|
||||||
if filters.MinRiskScore != nil {
|
|
||||||
query += fmt.Sprintf(" AND avg_risk_score >= $%d", argIdx)
|
|
||||||
args = append(args, *filters.MinRiskScore)
|
|
||||||
argIdx++
|
|
||||||
}
|
|
||||||
if filters.MaxRiskScore != nil {
|
|
||||||
query += fmt.Sprintf(" AND avg_risk_score <= $%d", argIdx)
|
|
||||||
args = append(args, *filters.MaxRiskScore)
|
|
||||||
argIdx++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
query += " ORDER BY updated_at DESC"
|
|
||||||
|
|
||||||
if filters != nil && filters.Limit > 0 {
|
|
||||||
query += fmt.Sprintf(" LIMIT $%d", argIdx)
|
|
||||||
args = append(args, filters.Limit)
|
|
||||||
argIdx++
|
|
||||||
|
|
||||||
if filters.Offset > 0 {
|
|
||||||
query += fmt.Sprintf(" OFFSET $%d", argIdx)
|
|
||||||
args = append(args, filters.Offset)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
rows, err := s.pool.Query(ctx, query, args...)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
var portfolios []Portfolio
|
|
||||||
for rows.Next() {
|
|
||||||
var p Portfolio
|
|
||||||
var status string
|
|
||||||
var settings []byte
|
|
||||||
|
|
||||||
err := rows.Scan(
|
|
||||||
&p.ID, &p.TenantID, &p.NamespaceID,
|
|
||||||
&p.Name, &p.Description, &status,
|
|
||||||
&p.Department, &p.BusinessUnit, &p.Owner, &p.OwnerEmail,
|
|
||||||
&p.TotalAssessments, &p.TotalRoadmaps, &p.TotalWorkshops,
|
|
||||||
&p.AvgRiskScore, &p.HighRiskCount, &p.ConditionalCount, &p.ApprovedCount,
|
|
||||||
&p.ComplianceScore, &settings,
|
|
||||||
&p.CreatedAt, &p.UpdatedAt, &p.CreatedBy, &p.ApprovedAt, &p.ApprovedBy,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
p.Status = PortfolioStatus(status)
|
|
||||||
json.Unmarshal(settings, &p.Settings)
|
|
||||||
|
|
||||||
portfolios = append(portfolios, p)
|
|
||||||
}
|
|
||||||
|
|
||||||
return portfolios, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// UpdatePortfolio updates a portfolio
|
|
||||||
func (s *Store) UpdatePortfolio(ctx context.Context, p *Portfolio) error {
|
|
||||||
p.UpdatedAt = time.Now().UTC()
|
|
||||||
|
|
||||||
settings, _ := json.Marshal(p.Settings)
|
|
||||||
|
|
||||||
_, err := s.pool.Exec(ctx, `
|
|
||||||
UPDATE portfolios SET
|
|
||||||
name = $2, description = $3, status = $4,
|
|
||||||
department = $5, business_unit = $6, owner = $7, owner_email = $8,
|
|
||||||
settings = $9,
|
|
||||||
updated_at = $10, approved_at = $11, approved_by = $12
|
|
||||||
WHERE id = $1
|
|
||||||
`,
|
|
||||||
p.ID, p.Name, p.Description, string(p.Status),
|
|
||||||
p.Department, p.BusinessUnit, p.Owner, p.OwnerEmail,
|
|
||||||
settings,
|
|
||||||
p.UpdatedAt, p.ApprovedAt, p.ApprovedBy,
|
|
||||||
)
|
|
||||||
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// DeletePortfolio deletes a portfolio and its items
|
|
||||||
func (s *Store) DeletePortfolio(ctx context.Context, id uuid.UUID) error {
|
|
||||||
// Delete items first
|
|
||||||
_, err := s.pool.Exec(ctx, "DELETE FROM portfolio_items WHERE portfolio_id = $1", id)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
// Delete portfolio
|
|
||||||
_, err = s.pool.Exec(ctx, "DELETE FROM portfolios WHERE id = $1", id)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Portfolio Item Operations
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
// AddItem adds an item to a portfolio
|
|
||||||
func (s *Store) AddItem(ctx context.Context, item *PortfolioItem) error {
|
|
||||||
item.ID = uuid.New()
|
|
||||||
item.AddedAt = time.Now().UTC()
|
|
||||||
|
|
||||||
tags, _ := json.Marshal(item.Tags)
|
|
||||||
|
|
||||||
// Check if item already exists in portfolio
|
|
||||||
var exists bool
|
|
||||||
s.pool.QueryRow(ctx,
|
|
||||||
"SELECT EXISTS(SELECT 1 FROM portfolio_items WHERE portfolio_id = $1 AND item_id = $2)",
|
|
||||||
item.PortfolioID, item.ItemID).Scan(&exists)
|
|
||||||
|
|
||||||
if exists {
|
|
||||||
return fmt.Errorf("item already exists in portfolio")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get max sort order
|
|
||||||
var maxSort int
|
|
||||||
s.pool.QueryRow(ctx,
|
|
||||||
"SELECT COALESCE(MAX(sort_order), 0) FROM portfolio_items WHERE portfolio_id = $1",
|
|
||||||
item.PortfolioID).Scan(&maxSort)
|
|
||||||
item.SortOrder = maxSort + 1
|
|
||||||
|
|
||||||
_, err := s.pool.Exec(ctx, `
|
|
||||||
INSERT INTO portfolio_items (
|
|
||||||
id, portfolio_id, item_type, item_id,
|
|
||||||
title, status, risk_level, risk_score, feasibility,
|
|
||||||
sort_order, tags, notes,
|
|
||||||
added_at, added_by
|
|
||||||
) VALUES (
|
|
||||||
$1, $2, $3, $4,
|
|
||||||
$5, $6, $7, $8, $9,
|
|
||||||
$10, $11, $12,
|
|
||||||
$13, $14
|
|
||||||
)
|
|
||||||
`,
|
|
||||||
item.ID, item.PortfolioID, string(item.ItemType), item.ItemID,
|
|
||||||
item.Title, item.Status, item.RiskLevel, item.RiskScore, item.Feasibility,
|
|
||||||
item.SortOrder, tags, item.Notes,
|
|
||||||
item.AddedAt, item.AddedBy,
|
|
||||||
)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update portfolio metrics
|
|
||||||
return s.RecalculateMetrics(ctx, item.PortfolioID)
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetItem retrieves a portfolio item by ID
|
|
||||||
func (s *Store) GetItem(ctx context.Context, id uuid.UUID) (*PortfolioItem, error) {
|
|
||||||
var item PortfolioItem
|
|
||||||
var itemType string
|
|
||||||
var tags []byte
|
|
||||||
|
|
||||||
err := s.pool.QueryRow(ctx, `
|
|
||||||
SELECT
|
|
||||||
id, portfolio_id, item_type, item_id,
|
|
||||||
title, status, risk_level, risk_score, feasibility,
|
|
||||||
sort_order, tags, notes,
|
|
||||||
added_at, added_by
|
|
||||||
FROM portfolio_items WHERE id = $1
|
|
||||||
`, id).Scan(
|
|
||||||
&item.ID, &item.PortfolioID, &itemType, &item.ItemID,
|
|
||||||
&item.Title, &item.Status, &item.RiskLevel, &item.RiskScore, &item.Feasibility,
|
|
||||||
&item.SortOrder, &tags, &item.Notes,
|
|
||||||
&item.AddedAt, &item.AddedBy,
|
|
||||||
)
|
|
||||||
|
|
||||||
if err == pgx.ErrNoRows {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
item.ItemType = ItemType(itemType)
|
|
||||||
json.Unmarshal(tags, &item.Tags)
|
|
||||||
|
|
||||||
return &item, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ListItems lists items in a portfolio
|
|
||||||
func (s *Store) ListItems(ctx context.Context, portfolioID uuid.UUID, itemType *ItemType) ([]PortfolioItem, error) {
|
|
||||||
query := `
|
|
||||||
SELECT
|
|
||||||
id, portfolio_id, item_type, item_id,
|
|
||||||
title, status, risk_level, risk_score, feasibility,
|
|
||||||
sort_order, tags, notes,
|
|
||||||
added_at, added_by
|
|
||||||
FROM portfolio_items WHERE portfolio_id = $1`
|
|
||||||
|
|
||||||
args := []interface{}{portfolioID}
|
|
||||||
if itemType != nil {
|
|
||||||
query += " AND item_type = $2"
|
|
||||||
args = append(args, string(*itemType))
|
|
||||||
}
|
|
||||||
|
|
||||||
query += " ORDER BY sort_order ASC"
|
|
||||||
|
|
||||||
rows, err := s.pool.Query(ctx, query, args...)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
var items []PortfolioItem
|
|
||||||
for rows.Next() {
|
|
||||||
var item PortfolioItem
|
|
||||||
var iType string
|
|
||||||
var tags []byte
|
|
||||||
|
|
||||||
err := rows.Scan(
|
|
||||||
&item.ID, &item.PortfolioID, &iType, &item.ItemID,
|
|
||||||
&item.Title, &item.Status, &item.RiskLevel, &item.RiskScore, &item.Feasibility,
|
|
||||||
&item.SortOrder, &tags, &item.Notes,
|
|
||||||
&item.AddedAt, &item.AddedBy,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
item.ItemType = ItemType(iType)
|
|
||||||
json.Unmarshal(tags, &item.Tags)
|
|
||||||
|
|
||||||
items = append(items, item)
|
|
||||||
}
|
|
||||||
|
|
||||||
return items, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// RemoveItem removes an item from a portfolio
|
|
||||||
func (s *Store) RemoveItem(ctx context.Context, id uuid.UUID) error {
|
|
||||||
// Get portfolio ID first
|
|
||||||
var portfolioID uuid.UUID
|
|
||||||
err := s.pool.QueryRow(ctx,
|
|
||||||
"SELECT portfolio_id FROM portfolio_items WHERE id = $1", id).Scan(&portfolioID)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = s.pool.Exec(ctx, "DELETE FROM portfolio_items WHERE id = $1", id)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Recalculate metrics
|
|
||||||
return s.RecalculateMetrics(ctx, portfolioID)
|
|
||||||
}
|
|
||||||
|
|
||||||
// UpdateItemOrder updates the sort order of items
|
|
||||||
func (s *Store) UpdateItemOrder(ctx context.Context, portfolioID uuid.UUID, itemIDs []uuid.UUID) error {
|
|
||||||
for i, id := range itemIDs {
|
|
||||||
_, err := s.pool.Exec(ctx,
|
|
||||||
"UPDATE portfolio_items SET sort_order = $2 WHERE id = $1 AND portfolio_id = $3",
|
|
||||||
id, i+1, portfolioID)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Merge Operations
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
// MergePortfolios merges source portfolio into target
|
|
||||||
func (s *Store) MergePortfolios(ctx context.Context, req *MergeRequest, userID uuid.UUID) (*MergeResult, error) {
|
|
||||||
result := &MergeResult{
|
|
||||||
ConflictsResolved: []MergeConflict{},
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get source items
|
|
||||||
sourceItems, err := s.ListItems(ctx, req.SourcePortfolioID, nil)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to get source items: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get target items for conflict detection
|
|
||||||
targetItems, err := s.ListItems(ctx, req.TargetPortfolioID, nil)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to get target items: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build map of existing items in target
|
|
||||||
targetItemMap := make(map[uuid.UUID]bool)
|
|
||||||
for _, item := range targetItems {
|
|
||||||
targetItemMap[item.ItemID] = true
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filter items based on strategy and options
|
|
||||||
for _, item := range sourceItems {
|
|
||||||
// Skip if not including this type
|
|
||||||
if item.ItemType == ItemTypeRoadmap && !req.IncludeRoadmaps {
|
|
||||||
result.ItemsSkipped++
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if item.ItemType == ItemTypeWorkshop && !req.IncludeWorkshops {
|
|
||||||
result.ItemsSkipped++
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for conflicts
|
|
||||||
if targetItemMap[item.ItemID] {
|
|
||||||
switch req.Strategy {
|
|
||||||
case MergeStrategyUnion:
|
|
||||||
// Skip duplicates
|
|
||||||
result.ItemsSkipped++
|
|
||||||
result.ConflictsResolved = append(result.ConflictsResolved, MergeConflict{
|
|
||||||
ItemID: item.ItemID,
|
|
||||||
ItemType: item.ItemType,
|
|
||||||
Reason: "duplicate",
|
|
||||||
Resolution: "kept_target",
|
|
||||||
})
|
|
||||||
case MergeStrategyReplace:
|
|
||||||
// Update existing item in target
|
|
||||||
// For now, just skip (could implement update logic)
|
|
||||||
result.ItemsUpdated++
|
|
||||||
result.ConflictsResolved = append(result.ConflictsResolved, MergeConflict{
|
|
||||||
ItemID: item.ItemID,
|
|
||||||
ItemType: item.ItemType,
|
|
||||||
Reason: "duplicate",
|
|
||||||
Resolution: "merged",
|
|
||||||
})
|
|
||||||
case MergeStrategyIntersect:
|
|
||||||
// Keep only items that exist in both
|
|
||||||
// Skip items not in target
|
|
||||||
result.ItemsSkipped++
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add item to target
|
|
||||||
newItem := &PortfolioItem{
|
|
||||||
PortfolioID: req.TargetPortfolioID,
|
|
||||||
ItemType: item.ItemType,
|
|
||||||
ItemID: item.ItemID,
|
|
||||||
Title: item.Title,
|
|
||||||
Status: item.Status,
|
|
||||||
RiskLevel: item.RiskLevel,
|
|
||||||
RiskScore: item.RiskScore,
|
|
||||||
Feasibility: item.Feasibility,
|
|
||||||
Tags: item.Tags,
|
|
||||||
Notes: item.Notes,
|
|
||||||
AddedBy: userID,
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := s.AddItem(ctx, newItem); err != nil {
|
|
||||||
// Skip on error but continue
|
|
||||||
result.ItemsSkipped++
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
result.ItemsAdded++
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete source if requested
|
|
||||||
if req.DeleteSource {
|
|
||||||
if err := s.DeletePortfolio(ctx, req.SourcePortfolioID); err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to delete source portfolio: %w", err)
|
|
||||||
}
|
|
||||||
result.SourceDeleted = true
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get updated target portfolio
|
|
||||||
result.TargetPortfolio, _ = s.GetPortfolio(ctx, req.TargetPortfolioID)
|
|
||||||
|
|
||||||
return result, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Metrics and Statistics
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
// RecalculateMetrics recalculates aggregated metrics for a portfolio
|
|
||||||
func (s *Store) RecalculateMetrics(ctx context.Context, portfolioID uuid.UUID) error {
|
|
||||||
// Count by type
|
|
||||||
var totalAssessments, totalRoadmaps, totalWorkshops int
|
|
||||||
s.pool.QueryRow(ctx,
|
|
||||||
"SELECT COUNT(*) FROM portfolio_items WHERE portfolio_id = $1 AND item_type = 'ASSESSMENT'",
|
|
||||||
portfolioID).Scan(&totalAssessments)
|
|
||||||
s.pool.QueryRow(ctx,
|
|
||||||
"SELECT COUNT(*) FROM portfolio_items WHERE portfolio_id = $1 AND item_type = 'ROADMAP'",
|
|
||||||
portfolioID).Scan(&totalRoadmaps)
|
|
||||||
s.pool.QueryRow(ctx,
|
|
||||||
"SELECT COUNT(*) FROM portfolio_items WHERE portfolio_id = $1 AND item_type = 'WORKSHOP'",
|
|
||||||
portfolioID).Scan(&totalWorkshops)
|
|
||||||
|
|
||||||
// Calculate risk metrics from assessments
|
|
||||||
var avgRiskScore float64
|
|
||||||
var highRiskCount, conditionalCount, approvedCount int
|
|
||||||
|
|
||||||
s.pool.QueryRow(ctx, `
|
|
||||||
SELECT COALESCE(AVG(risk_score), 0)
|
|
||||||
FROM portfolio_items
|
|
||||||
WHERE portfolio_id = $1 AND item_type = 'ASSESSMENT'
|
|
||||||
`, portfolioID).Scan(&avgRiskScore)
|
|
||||||
|
|
||||||
s.pool.QueryRow(ctx, `
|
|
||||||
SELECT COUNT(*)
|
|
||||||
FROM portfolio_items
|
|
||||||
WHERE portfolio_id = $1 AND item_type = 'ASSESSMENT' AND risk_level IN ('HIGH', 'UNACCEPTABLE')
|
|
||||||
`, portfolioID).Scan(&highRiskCount)
|
|
||||||
|
|
||||||
s.pool.QueryRow(ctx, `
|
|
||||||
SELECT COUNT(*)
|
|
||||||
FROM portfolio_items
|
|
||||||
WHERE portfolio_id = $1 AND item_type = 'ASSESSMENT' AND feasibility = 'CONDITIONAL'
|
|
||||||
`, portfolioID).Scan(&conditionalCount)
|
|
||||||
|
|
||||||
s.pool.QueryRow(ctx, `
|
|
||||||
SELECT COUNT(*)
|
|
||||||
FROM portfolio_items
|
|
||||||
WHERE portfolio_id = $1 AND item_type = 'ASSESSMENT' AND feasibility = 'YES'
|
|
||||||
`, portfolioID).Scan(&approvedCount)
|
|
||||||
|
|
||||||
// Calculate compliance score (simplified: % of approved items)
|
|
||||||
var complianceScore float64
|
|
||||||
if totalAssessments > 0 {
|
|
||||||
complianceScore = (float64(approvedCount) / float64(totalAssessments)) * 100
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update portfolio
|
|
||||||
_, err := s.pool.Exec(ctx, `
|
|
||||||
UPDATE portfolios SET
|
|
||||||
total_assessments = $2,
|
|
||||||
total_roadmaps = $3,
|
|
||||||
total_workshops = $4,
|
|
||||||
avg_risk_score = $5,
|
|
||||||
high_risk_count = $6,
|
|
||||||
conditional_count = $7,
|
|
||||||
approved_count = $8,
|
|
||||||
compliance_score = $9,
|
|
||||||
updated_at = NOW()
|
|
||||||
WHERE id = $1
|
|
||||||
`,
|
|
||||||
portfolioID,
|
|
||||||
totalAssessments, totalRoadmaps, totalWorkshops,
|
|
||||||
avgRiskScore, highRiskCount, conditionalCount, approvedCount,
|
|
||||||
complianceScore,
|
|
||||||
)
|
|
||||||
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetPortfolioStats returns detailed statistics for a portfolio
|
|
||||||
func (s *Store) GetPortfolioStats(ctx context.Context, portfolioID uuid.UUID) (*PortfolioStats, error) {
|
|
||||||
stats := &PortfolioStats{
|
|
||||||
ItemsByType: make(map[ItemType]int),
|
|
||||||
}
|
|
||||||
|
|
||||||
// Total items
|
|
||||||
s.pool.QueryRow(ctx,
|
|
||||||
"SELECT COUNT(*) FROM portfolio_items WHERE portfolio_id = $1",
|
|
||||||
portfolioID).Scan(&stats.TotalItems)
|
|
||||||
|
|
||||||
// Items by type
|
|
||||||
rows, _ := s.pool.Query(ctx, `
|
|
||||||
SELECT item_type, COUNT(*)
|
|
||||||
FROM portfolio_items
|
|
||||||
WHERE portfolio_id = $1
|
|
||||||
GROUP BY item_type
|
|
||||||
`, portfolioID)
|
|
||||||
if rows != nil {
|
|
||||||
defer rows.Close()
|
|
||||||
for rows.Next() {
|
|
||||||
var itemType string
|
|
||||||
var count int
|
|
||||||
rows.Scan(&itemType, &count)
|
|
||||||
stats.ItemsByType[ItemType(itemType)] = count
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Risk distribution
|
|
||||||
s.pool.QueryRow(ctx, `
|
|
||||||
SELECT COUNT(*) FROM portfolio_items
|
|
||||||
WHERE portfolio_id = $1 AND risk_level = 'MINIMAL'
|
|
||||||
`, portfolioID).Scan(&stats.RiskDistribution.Minimal)
|
|
||||||
|
|
||||||
s.pool.QueryRow(ctx, `
|
|
||||||
SELECT COUNT(*) FROM portfolio_items
|
|
||||||
WHERE portfolio_id = $1 AND risk_level = 'LOW'
|
|
||||||
`, portfolioID).Scan(&stats.RiskDistribution.Low)
|
|
||||||
|
|
||||||
s.pool.QueryRow(ctx, `
|
|
||||||
SELECT COUNT(*) FROM portfolio_items
|
|
||||||
WHERE portfolio_id = $1 AND risk_level = 'MEDIUM'
|
|
||||||
`, portfolioID).Scan(&stats.RiskDistribution.Medium)
|
|
||||||
|
|
||||||
s.pool.QueryRow(ctx, `
|
|
||||||
SELECT COUNT(*) FROM portfolio_items
|
|
||||||
WHERE portfolio_id = $1 AND risk_level = 'HIGH'
|
|
||||||
`, portfolioID).Scan(&stats.RiskDistribution.High)
|
|
||||||
|
|
||||||
s.pool.QueryRow(ctx, `
|
|
||||||
SELECT COUNT(*) FROM portfolio_items
|
|
||||||
WHERE portfolio_id = $1 AND risk_level = 'UNACCEPTABLE'
|
|
||||||
`, portfolioID).Scan(&stats.RiskDistribution.Unacceptable)
|
|
||||||
|
|
||||||
// Feasibility distribution
|
|
||||||
s.pool.QueryRow(ctx, `
|
|
||||||
SELECT COUNT(*) FROM portfolio_items
|
|
||||||
WHERE portfolio_id = $1 AND feasibility = 'YES'
|
|
||||||
`, portfolioID).Scan(&stats.FeasibilityDist.Yes)
|
|
||||||
|
|
||||||
s.pool.QueryRow(ctx, `
|
|
||||||
SELECT COUNT(*) FROM portfolio_items
|
|
||||||
WHERE portfolio_id = $1 AND feasibility = 'CONDITIONAL'
|
|
||||||
`, portfolioID).Scan(&stats.FeasibilityDist.Conditional)
|
|
||||||
|
|
||||||
s.pool.QueryRow(ctx, `
|
|
||||||
SELECT COUNT(*) FROM portfolio_items
|
|
||||||
WHERE portfolio_id = $1 AND feasibility = 'NO'
|
|
||||||
`, portfolioID).Scan(&stats.FeasibilityDist.No)
|
|
||||||
|
|
||||||
// Average risk score
|
|
||||||
s.pool.QueryRow(ctx, `
|
|
||||||
SELECT COALESCE(AVG(risk_score), 0)
|
|
||||||
FROM portfolio_items
|
|
||||||
WHERE portfolio_id = $1 AND item_type = 'ASSESSMENT'
|
|
||||||
`, portfolioID).Scan(&stats.AvgRiskScore)
|
|
||||||
|
|
||||||
// Compliance score
|
|
||||||
s.pool.QueryRow(ctx,
|
|
||||||
"SELECT compliance_score FROM portfolios WHERE id = $1",
|
|
||||||
portfolioID).Scan(&stats.ComplianceScore)
|
|
||||||
|
|
||||||
// DSFA required count
|
|
||||||
// This would need to join with ucca_assessments to get dsfa_recommended
|
|
||||||
// For now, estimate from high risk items
|
|
||||||
stats.DSFARequired = stats.RiskDistribution.High + stats.RiskDistribution.Unacceptable
|
|
||||||
|
|
||||||
// Controls required (items with CONDITIONAL feasibility)
|
|
||||||
stats.ControlsRequired = stats.FeasibilityDist.Conditional
|
|
||||||
|
|
||||||
stats.LastUpdated = time.Now().UTC()
|
|
||||||
|
|
||||||
return stats, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetPortfolioSummary returns a complete portfolio summary
|
|
||||||
func (s *Store) GetPortfolioSummary(ctx context.Context, portfolioID uuid.UUID) (*PortfolioSummary, error) {
|
|
||||||
portfolio, err := s.GetPortfolio(ctx, portfolioID)
|
|
||||||
if err != nil || portfolio == nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
items, err := s.ListItems(ctx, portfolioID, nil)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
stats, err := s.GetPortfolioStats(ctx, portfolioID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return &PortfolioSummary{
|
|
||||||
Portfolio: portfolio,
|
|
||||||
Items: items,
|
|
||||||
RiskDistribution: stats.RiskDistribution,
|
|
||||||
FeasibilityDist: stats.FeasibilityDist,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Bulk Operations
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
// BulkAddItems adds multiple items to a portfolio
|
|
||||||
func (s *Store) BulkAddItems(ctx context.Context, portfolioID uuid.UUID, items []PortfolioItem, userID uuid.UUID) (*BulkAddItemsResponse, error) {
|
|
||||||
result := &BulkAddItemsResponse{
|
|
||||||
Errors: []string{},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, item := range items {
|
|
||||||
item.PortfolioID = portfolioID
|
|
||||||
item.AddedBy = userID
|
|
||||||
|
|
||||||
// Fetch item info from source table if not provided
|
|
||||||
if item.Title == "" {
|
|
||||||
s.populateItemInfo(ctx, &item)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := s.AddItem(ctx, &item); err != nil {
|
|
||||||
result.Skipped++
|
|
||||||
result.Errors = append(result.Errors, fmt.Sprintf("%s: %v", item.ItemID, err))
|
|
||||||
} else {
|
|
||||||
result.Added++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return result, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// populateItemInfo fetches item metadata from the source table
|
|
||||||
func (s *Store) populateItemInfo(ctx context.Context, item *PortfolioItem) {
|
|
||||||
switch item.ItemType {
|
|
||||||
case ItemTypeAssessment:
|
|
||||||
s.pool.QueryRow(ctx, `
|
|
||||||
SELECT title, feasibility, risk_level, risk_score, status
|
|
||||||
FROM ucca_assessments WHERE id = $1
|
|
||||||
`, item.ItemID).Scan(&item.Title, &item.Feasibility, &item.RiskLevel, &item.RiskScore, &item.Status)
|
|
||||||
|
|
||||||
case ItemTypeRoadmap:
|
|
||||||
s.pool.QueryRow(ctx, `
|
|
||||||
SELECT name, status
|
|
||||||
FROM roadmaps WHERE id = $1
|
|
||||||
`, item.ItemID).Scan(&item.Title, &item.Status)
|
|
||||||
|
|
||||||
case ItemTypeWorkshop:
|
|
||||||
s.pool.QueryRow(ctx, `
|
|
||||||
SELECT title, status
|
|
||||||
FROM workshop_sessions WHERE id = $1
|
|
||||||
`, item.ItemID).Scan(&item.Title, &item.Status)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Activity Tracking
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
// LogActivity logs an activity entry for a portfolio
|
|
||||||
func (s *Store) LogActivity(ctx context.Context, portfolioID uuid.UUID, entry *ActivityEntry) error {
|
|
||||||
_, err := s.pool.Exec(ctx, `
|
|
||||||
INSERT INTO portfolio_activity (
|
|
||||||
id, portfolio_id, timestamp, action,
|
|
||||||
item_type, item_id, item_title, user_id
|
|
||||||
) VALUES (
|
|
||||||
$1, $2, $3, $4, $5, $6, $7, $8
|
|
||||||
)
|
|
||||||
`,
|
|
||||||
uuid.New(), portfolioID, entry.Timestamp, entry.Action,
|
|
||||||
string(entry.ItemType), entry.ItemID, entry.ItemTitle, entry.UserID,
|
|
||||||
)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetRecentActivity retrieves recent activity for a portfolio
|
|
||||||
func (s *Store) GetRecentActivity(ctx context.Context, portfolioID uuid.UUID, limit int) ([]ActivityEntry, error) {
|
|
||||||
if limit <= 0 {
|
|
||||||
limit = 20
|
|
||||||
}
|
|
||||||
|
|
||||||
rows, err := s.pool.Query(ctx, `
|
|
||||||
SELECT timestamp, action, item_type, item_id, item_title, user_id
|
|
||||||
FROM portfolio_activity
|
|
||||||
WHERE portfolio_id = $1
|
|
||||||
ORDER BY timestamp DESC
|
|
||||||
LIMIT $2
|
|
||||||
`, portfolioID, limit)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
var activities []ActivityEntry
|
|
||||||
for rows.Next() {
|
|
||||||
var entry ActivityEntry
|
|
||||||
var itemType string
|
|
||||||
err := rows.Scan(
|
|
||||||
&entry.Timestamp, &entry.Action, &itemType,
|
|
||||||
&entry.ItemID, &entry.ItemTitle, &entry.UserID,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
entry.ItemType = ItemType(itemType)
|
|
||||||
activities = append(activities, entry)
|
|
||||||
}
|
|
||||||
|
|
||||||
return activities, nil
|
|
||||||
}
|
|
||||||
281
ai-compliance-sdk/internal/portfolio/store_items.go
Normal file
281
ai-compliance-sdk/internal/portfolio/store_items.go
Normal file
@@ -0,0 +1,281 @@
|
|||||||
|
package portfolio
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Portfolio Item Operations
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
// AddItem adds an item to a portfolio
|
||||||
|
func (s *Store) AddItem(ctx context.Context, item *PortfolioItem) error {
|
||||||
|
item.ID = uuid.New()
|
||||||
|
item.AddedAt = time.Now().UTC()
|
||||||
|
|
||||||
|
tags, _ := json.Marshal(item.Tags)
|
||||||
|
|
||||||
|
// Check if item already exists in portfolio
|
||||||
|
var exists bool
|
||||||
|
s.pool.QueryRow(ctx,
|
||||||
|
"SELECT EXISTS(SELECT 1 FROM portfolio_items WHERE portfolio_id = $1 AND item_id = $2)",
|
||||||
|
item.PortfolioID, item.ItemID).Scan(&exists)
|
||||||
|
|
||||||
|
if exists {
|
||||||
|
return fmt.Errorf("item already exists in portfolio")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get max sort order
|
||||||
|
var maxSort int
|
||||||
|
s.pool.QueryRow(ctx,
|
||||||
|
"SELECT COALESCE(MAX(sort_order), 0) FROM portfolio_items WHERE portfolio_id = $1",
|
||||||
|
item.PortfolioID).Scan(&maxSort)
|
||||||
|
item.SortOrder = maxSort + 1
|
||||||
|
|
||||||
|
_, err := s.pool.Exec(ctx, `
|
||||||
|
INSERT INTO portfolio_items (
|
||||||
|
id, portfolio_id, item_type, item_id,
|
||||||
|
title, status, risk_level, risk_score, feasibility,
|
||||||
|
sort_order, tags, notes,
|
||||||
|
added_at, added_by
|
||||||
|
) VALUES (
|
||||||
|
$1, $2, $3, $4,
|
||||||
|
$5, $6, $7, $8, $9,
|
||||||
|
$10, $11, $12,
|
||||||
|
$13, $14
|
||||||
|
)
|
||||||
|
`,
|
||||||
|
item.ID, item.PortfolioID, string(item.ItemType), item.ItemID,
|
||||||
|
item.Title, item.Status, item.RiskLevel, item.RiskScore, item.Feasibility,
|
||||||
|
item.SortOrder, tags, item.Notes,
|
||||||
|
item.AddedAt, item.AddedBy,
|
||||||
|
)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update portfolio metrics
|
||||||
|
return s.RecalculateMetrics(ctx, item.PortfolioID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetItem retrieves a portfolio item by ID
|
||||||
|
func (s *Store) GetItem(ctx context.Context, id uuid.UUID) (*PortfolioItem, error) {
|
||||||
|
var item PortfolioItem
|
||||||
|
var itemType string
|
||||||
|
var tags []byte
|
||||||
|
|
||||||
|
err := s.pool.QueryRow(ctx, `
|
||||||
|
SELECT
|
||||||
|
id, portfolio_id, item_type, item_id,
|
||||||
|
title, status, risk_level, risk_score, feasibility,
|
||||||
|
sort_order, tags, notes,
|
||||||
|
added_at, added_by
|
||||||
|
FROM portfolio_items WHERE id = $1
|
||||||
|
`, id).Scan(
|
||||||
|
&item.ID, &item.PortfolioID, &itemType, &item.ItemID,
|
||||||
|
&item.Title, &item.Status, &item.RiskLevel, &item.RiskScore, &item.Feasibility,
|
||||||
|
&item.SortOrder, &tags, &item.Notes,
|
||||||
|
&item.AddedAt, &item.AddedBy,
|
||||||
|
)
|
||||||
|
|
||||||
|
if err == pgx.ErrNoRows {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
item.ItemType = ItemType(itemType)
|
||||||
|
json.Unmarshal(tags, &item.Tags)
|
||||||
|
|
||||||
|
return &item, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListItems lists items in a portfolio
|
||||||
|
func (s *Store) ListItems(ctx context.Context, portfolioID uuid.UUID, itemType *ItemType) ([]PortfolioItem, error) {
|
||||||
|
query := `
|
||||||
|
SELECT
|
||||||
|
id, portfolio_id, item_type, item_id,
|
||||||
|
title, status, risk_level, risk_score, feasibility,
|
||||||
|
sort_order, tags, notes,
|
||||||
|
added_at, added_by
|
||||||
|
FROM portfolio_items WHERE portfolio_id = $1`
|
||||||
|
|
||||||
|
args := []interface{}{portfolioID}
|
||||||
|
if itemType != nil {
|
||||||
|
query += " AND item_type = $2"
|
||||||
|
args = append(args, string(*itemType))
|
||||||
|
}
|
||||||
|
|
||||||
|
query += " ORDER BY sort_order ASC"
|
||||||
|
|
||||||
|
rows, err := s.pool.Query(ctx, query, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var items []PortfolioItem
|
||||||
|
for rows.Next() {
|
||||||
|
var item PortfolioItem
|
||||||
|
var iType string
|
||||||
|
var tags []byte
|
||||||
|
|
||||||
|
err := rows.Scan(
|
||||||
|
&item.ID, &item.PortfolioID, &iType, &item.ItemID,
|
||||||
|
&item.Title, &item.Status, &item.RiskLevel, &item.RiskScore, &item.Feasibility,
|
||||||
|
&item.SortOrder, &tags, &item.Notes,
|
||||||
|
&item.AddedAt, &item.AddedBy,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
item.ItemType = ItemType(iType)
|
||||||
|
json.Unmarshal(tags, &item.Tags)
|
||||||
|
|
||||||
|
items = append(items, item)
|
||||||
|
}
|
||||||
|
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveItem removes an item from a portfolio
|
||||||
|
func (s *Store) RemoveItem(ctx context.Context, id uuid.UUID) error {
|
||||||
|
// Get portfolio ID first
|
||||||
|
var portfolioID uuid.UUID
|
||||||
|
err := s.pool.QueryRow(ctx,
|
||||||
|
"SELECT portfolio_id FROM portfolio_items WHERE id = $1", id).Scan(&portfolioID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = s.pool.Exec(ctx, "DELETE FROM portfolio_items WHERE id = $1", id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recalculate metrics
|
||||||
|
return s.RecalculateMetrics(ctx, portfolioID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateItemOrder updates the sort order of items
|
||||||
|
func (s *Store) UpdateItemOrder(ctx context.Context, portfolioID uuid.UUID, itemIDs []uuid.UUID) error {
|
||||||
|
for i, id := range itemIDs {
|
||||||
|
_, err := s.pool.Exec(ctx,
|
||||||
|
"UPDATE portfolio_items SET sort_order = $2 WHERE id = $1 AND portfolio_id = $3",
|
||||||
|
id, i+1, portfolioID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Merge Operations
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
// MergePortfolios merges source portfolio into target
|
||||||
|
func (s *Store) MergePortfolios(ctx context.Context, req *MergeRequest, userID uuid.UUID) (*MergeResult, error) {
|
||||||
|
result := &MergeResult{
|
||||||
|
ConflictsResolved: []MergeConflict{},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get source items
|
||||||
|
sourceItems, err := s.ListItems(ctx, req.SourcePortfolioID, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get source items: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get target items for conflict detection
|
||||||
|
targetItems, err := s.ListItems(ctx, req.TargetPortfolioID, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get target items: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build map of existing items in target
|
||||||
|
targetItemMap := make(map[uuid.UUID]bool)
|
||||||
|
for _, item := range targetItems {
|
||||||
|
targetItemMap[item.ItemID] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter items based on strategy and options
|
||||||
|
for _, item := range sourceItems {
|
||||||
|
// Skip if not including this type
|
||||||
|
if item.ItemType == ItemTypeRoadmap && !req.IncludeRoadmaps {
|
||||||
|
result.ItemsSkipped++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if item.ItemType == ItemTypeWorkshop && !req.IncludeWorkshops {
|
||||||
|
result.ItemsSkipped++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for conflicts
|
||||||
|
if targetItemMap[item.ItemID] {
|
||||||
|
switch req.Strategy {
|
||||||
|
case MergeStrategyUnion:
|
||||||
|
result.ItemsSkipped++
|
||||||
|
result.ConflictsResolved = append(result.ConflictsResolved, MergeConflict{
|
||||||
|
ItemID: item.ItemID,
|
||||||
|
ItemType: item.ItemType,
|
||||||
|
Reason: "duplicate",
|
||||||
|
Resolution: "kept_target",
|
||||||
|
})
|
||||||
|
case MergeStrategyReplace:
|
||||||
|
result.ItemsUpdated++
|
||||||
|
result.ConflictsResolved = append(result.ConflictsResolved, MergeConflict{
|
||||||
|
ItemID: item.ItemID,
|
||||||
|
ItemType: item.ItemType,
|
||||||
|
Reason: "duplicate",
|
||||||
|
Resolution: "merged",
|
||||||
|
})
|
||||||
|
case MergeStrategyIntersect:
|
||||||
|
result.ItemsSkipped++
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add item to target
|
||||||
|
newItem := &PortfolioItem{
|
||||||
|
PortfolioID: req.TargetPortfolioID,
|
||||||
|
ItemType: item.ItemType,
|
||||||
|
ItemID: item.ItemID,
|
||||||
|
Title: item.Title,
|
||||||
|
Status: item.Status,
|
||||||
|
RiskLevel: item.RiskLevel,
|
||||||
|
RiskScore: item.RiskScore,
|
||||||
|
Feasibility: item.Feasibility,
|
||||||
|
Tags: item.Tags,
|
||||||
|
Notes: item.Notes,
|
||||||
|
AddedBy: userID,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.AddItem(ctx, newItem); err != nil {
|
||||||
|
result.ItemsSkipped++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
result.ItemsAdded++
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete source if requested
|
||||||
|
if req.DeleteSource {
|
||||||
|
if err := s.DeletePortfolio(ctx, req.SourcePortfolioID); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to delete source portfolio: %w", err)
|
||||||
|
}
|
||||||
|
result.SourceDeleted = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get updated target portfolio
|
||||||
|
result.TargetPortfolio, _ = s.GetPortfolio(ctx, req.TargetPortfolioID)
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
311
ai-compliance-sdk/internal/portfolio/store_metrics.go
Normal file
311
ai-compliance-sdk/internal/portfolio/store_metrics.go
Normal file
@@ -0,0 +1,311 @@
|
|||||||
|
package portfolio
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Metrics and Statistics
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
// RecalculateMetrics recalculates aggregated metrics for a portfolio
|
||||||
|
func (s *Store) RecalculateMetrics(ctx context.Context, portfolioID uuid.UUID) error {
|
||||||
|
// Count by type
|
||||||
|
var totalAssessments, totalRoadmaps, totalWorkshops int
|
||||||
|
s.pool.QueryRow(ctx,
|
||||||
|
"SELECT COUNT(*) FROM portfolio_items WHERE portfolio_id = $1 AND item_type = 'ASSESSMENT'",
|
||||||
|
portfolioID).Scan(&totalAssessments)
|
||||||
|
s.pool.QueryRow(ctx,
|
||||||
|
"SELECT COUNT(*) FROM portfolio_items WHERE portfolio_id = $1 AND item_type = 'ROADMAP'",
|
||||||
|
portfolioID).Scan(&totalRoadmaps)
|
||||||
|
s.pool.QueryRow(ctx,
|
||||||
|
"SELECT COUNT(*) FROM portfolio_items WHERE portfolio_id = $1 AND item_type = 'WORKSHOP'",
|
||||||
|
portfolioID).Scan(&totalWorkshops)
|
||||||
|
|
||||||
|
// Calculate risk metrics from assessments
|
||||||
|
var avgRiskScore float64
|
||||||
|
var highRiskCount, conditionalCount, approvedCount int
|
||||||
|
|
||||||
|
s.pool.QueryRow(ctx, `
|
||||||
|
SELECT COALESCE(AVG(risk_score), 0)
|
||||||
|
FROM portfolio_items
|
||||||
|
WHERE portfolio_id = $1 AND item_type = 'ASSESSMENT'
|
||||||
|
`, portfolioID).Scan(&avgRiskScore)
|
||||||
|
|
||||||
|
s.pool.QueryRow(ctx, `
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM portfolio_items
|
||||||
|
WHERE portfolio_id = $1 AND item_type = 'ASSESSMENT' AND risk_level IN ('HIGH', 'UNACCEPTABLE')
|
||||||
|
`, portfolioID).Scan(&highRiskCount)
|
||||||
|
|
||||||
|
s.pool.QueryRow(ctx, `
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM portfolio_items
|
||||||
|
WHERE portfolio_id = $1 AND item_type = 'ASSESSMENT' AND feasibility = 'CONDITIONAL'
|
||||||
|
`, portfolioID).Scan(&conditionalCount)
|
||||||
|
|
||||||
|
s.pool.QueryRow(ctx, `
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM portfolio_items
|
||||||
|
WHERE portfolio_id = $1 AND item_type = 'ASSESSMENT' AND feasibility = 'YES'
|
||||||
|
`, portfolioID).Scan(&approvedCount)
|
||||||
|
|
||||||
|
// Calculate compliance score (simplified: % of approved items)
|
||||||
|
var complianceScore float64
|
||||||
|
if totalAssessments > 0 {
|
||||||
|
complianceScore = (float64(approvedCount) / float64(totalAssessments)) * 100
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update portfolio
|
||||||
|
_, err := s.pool.Exec(ctx, `
|
||||||
|
UPDATE portfolios SET
|
||||||
|
total_assessments = $2,
|
||||||
|
total_roadmaps = $3,
|
||||||
|
total_workshops = $4,
|
||||||
|
avg_risk_score = $5,
|
||||||
|
high_risk_count = $6,
|
||||||
|
conditional_count = $7,
|
||||||
|
approved_count = $8,
|
||||||
|
compliance_score = $9,
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE id = $1
|
||||||
|
`,
|
||||||
|
portfolioID,
|
||||||
|
totalAssessments, totalRoadmaps, totalWorkshops,
|
||||||
|
avgRiskScore, highRiskCount, conditionalCount, approvedCount,
|
||||||
|
complianceScore,
|
||||||
|
)
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPortfolioStats returns detailed statistics for a portfolio
|
||||||
|
func (s *Store) GetPortfolioStats(ctx context.Context, portfolioID uuid.UUID) (*PortfolioStats, error) {
|
||||||
|
stats := &PortfolioStats{
|
||||||
|
ItemsByType: make(map[ItemType]int),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Total items
|
||||||
|
s.pool.QueryRow(ctx,
|
||||||
|
"SELECT COUNT(*) FROM portfolio_items WHERE portfolio_id = $1",
|
||||||
|
portfolioID).Scan(&stats.TotalItems)
|
||||||
|
|
||||||
|
// Items by type
|
||||||
|
rows, _ := s.pool.Query(ctx, `
|
||||||
|
SELECT item_type, COUNT(*)
|
||||||
|
FROM portfolio_items
|
||||||
|
WHERE portfolio_id = $1
|
||||||
|
GROUP BY item_type
|
||||||
|
`, portfolioID)
|
||||||
|
if rows != nil {
|
||||||
|
defer rows.Close()
|
||||||
|
for rows.Next() {
|
||||||
|
var itemType string
|
||||||
|
var count int
|
||||||
|
rows.Scan(&itemType, &count)
|
||||||
|
stats.ItemsByType[ItemType(itemType)] = count
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Risk distribution
|
||||||
|
s.pool.QueryRow(ctx, `
|
||||||
|
SELECT COUNT(*) FROM portfolio_items
|
||||||
|
WHERE portfolio_id = $1 AND risk_level = 'MINIMAL'
|
||||||
|
`, portfolioID).Scan(&stats.RiskDistribution.Minimal)
|
||||||
|
|
||||||
|
s.pool.QueryRow(ctx, `
|
||||||
|
SELECT COUNT(*) FROM portfolio_items
|
||||||
|
WHERE portfolio_id = $1 AND risk_level = 'LOW'
|
||||||
|
`, portfolioID).Scan(&stats.RiskDistribution.Low)
|
||||||
|
|
||||||
|
s.pool.QueryRow(ctx, `
|
||||||
|
SELECT COUNT(*) FROM portfolio_items
|
||||||
|
WHERE portfolio_id = $1 AND risk_level = 'MEDIUM'
|
||||||
|
`, portfolioID).Scan(&stats.RiskDistribution.Medium)
|
||||||
|
|
||||||
|
s.pool.QueryRow(ctx, `
|
||||||
|
SELECT COUNT(*) FROM portfolio_items
|
||||||
|
WHERE portfolio_id = $1 AND risk_level = 'HIGH'
|
||||||
|
`, portfolioID).Scan(&stats.RiskDistribution.High)
|
||||||
|
|
||||||
|
s.pool.QueryRow(ctx, `
|
||||||
|
SELECT COUNT(*) FROM portfolio_items
|
||||||
|
WHERE portfolio_id = $1 AND risk_level = 'UNACCEPTABLE'
|
||||||
|
`, portfolioID).Scan(&stats.RiskDistribution.Unacceptable)
|
||||||
|
|
||||||
|
// Feasibility distribution
|
||||||
|
s.pool.QueryRow(ctx, `
|
||||||
|
SELECT COUNT(*) FROM portfolio_items
|
||||||
|
WHERE portfolio_id = $1 AND feasibility = 'YES'
|
||||||
|
`, portfolioID).Scan(&stats.FeasibilityDist.Yes)
|
||||||
|
|
||||||
|
s.pool.QueryRow(ctx, `
|
||||||
|
SELECT COUNT(*) FROM portfolio_items
|
||||||
|
WHERE portfolio_id = $1 AND feasibility = 'CONDITIONAL'
|
||||||
|
`, portfolioID).Scan(&stats.FeasibilityDist.Conditional)
|
||||||
|
|
||||||
|
s.pool.QueryRow(ctx, `
|
||||||
|
SELECT COUNT(*) FROM portfolio_items
|
||||||
|
WHERE portfolio_id = $1 AND feasibility = 'NO'
|
||||||
|
`, portfolioID).Scan(&stats.FeasibilityDist.No)
|
||||||
|
|
||||||
|
// Average risk score
|
||||||
|
s.pool.QueryRow(ctx, `
|
||||||
|
SELECT COALESCE(AVG(risk_score), 0)
|
||||||
|
FROM portfolio_items
|
||||||
|
WHERE portfolio_id = $1 AND item_type = 'ASSESSMENT'
|
||||||
|
`, portfolioID).Scan(&stats.AvgRiskScore)
|
||||||
|
|
||||||
|
// Compliance score
|
||||||
|
s.pool.QueryRow(ctx,
|
||||||
|
"SELECT compliance_score FROM portfolios WHERE id = $1",
|
||||||
|
portfolioID).Scan(&stats.ComplianceScore)
|
||||||
|
|
||||||
|
// DSFA required count — estimate from high risk items
|
||||||
|
stats.DSFARequired = stats.RiskDistribution.High + stats.RiskDistribution.Unacceptable
|
||||||
|
|
||||||
|
// Controls required (items with CONDITIONAL feasibility)
|
||||||
|
stats.ControlsRequired = stats.FeasibilityDist.Conditional
|
||||||
|
|
||||||
|
stats.LastUpdated = time.Now().UTC()
|
||||||
|
|
||||||
|
return stats, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPortfolioSummary returns a complete portfolio summary
|
||||||
|
func (s *Store) GetPortfolioSummary(ctx context.Context, portfolioID uuid.UUID) (*PortfolioSummary, error) {
|
||||||
|
portfolio, err := s.GetPortfolio(ctx, portfolioID)
|
||||||
|
if err != nil || portfolio == nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
items, err := s.ListItems(ctx, portfolioID, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
stats, err := s.GetPortfolioStats(ctx, portfolioID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &PortfolioSummary{
|
||||||
|
Portfolio: portfolio,
|
||||||
|
Items: items,
|
||||||
|
RiskDistribution: stats.RiskDistribution,
|
||||||
|
FeasibilityDist: stats.FeasibilityDist,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Bulk Operations
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
// BulkAddItems adds multiple items to a portfolio
|
||||||
|
func (s *Store) BulkAddItems(ctx context.Context, portfolioID uuid.UUID, items []PortfolioItem, userID uuid.UUID) (*BulkAddItemsResponse, error) {
|
||||||
|
result := &BulkAddItemsResponse{
|
||||||
|
Errors: []string{},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, item := range items {
|
||||||
|
item.PortfolioID = portfolioID
|
||||||
|
item.AddedBy = userID
|
||||||
|
|
||||||
|
// Fetch item info from source table if not provided
|
||||||
|
if item.Title == "" {
|
||||||
|
s.populateItemInfo(ctx, &item)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.AddItem(ctx, &item); err != nil {
|
||||||
|
result.Skipped++
|
||||||
|
result.Errors = append(result.Errors, fmt.Sprintf("%s: %v", item.ItemID, err))
|
||||||
|
} else {
|
||||||
|
result.Added++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// populateItemInfo fetches item metadata from the source table
|
||||||
|
func (s *Store) populateItemInfo(ctx context.Context, item *PortfolioItem) {
|
||||||
|
switch item.ItemType {
|
||||||
|
case ItemTypeAssessment:
|
||||||
|
s.pool.QueryRow(ctx, `
|
||||||
|
SELECT title, feasibility, risk_level, risk_score, status
|
||||||
|
FROM ucca_assessments WHERE id = $1
|
||||||
|
`, item.ItemID).Scan(&item.Title, &item.Feasibility, &item.RiskLevel, &item.RiskScore, &item.Status)
|
||||||
|
|
||||||
|
case ItemTypeRoadmap:
|
||||||
|
s.pool.QueryRow(ctx, `
|
||||||
|
SELECT name, status
|
||||||
|
FROM roadmaps WHERE id = $1
|
||||||
|
`, item.ItemID).Scan(&item.Title, &item.Status)
|
||||||
|
|
||||||
|
case ItemTypeWorkshop:
|
||||||
|
s.pool.QueryRow(ctx, `
|
||||||
|
SELECT title, status
|
||||||
|
FROM workshop_sessions WHERE id = $1
|
||||||
|
`, item.ItemID).Scan(&item.Title, &item.Status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Activity Tracking
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
// LogActivity logs an activity entry for a portfolio
|
||||||
|
func (s *Store) LogActivity(ctx context.Context, portfolioID uuid.UUID, entry *ActivityEntry) error {
|
||||||
|
_, err := s.pool.Exec(ctx, `
|
||||||
|
INSERT INTO portfolio_activity (
|
||||||
|
id, portfolio_id, timestamp, action,
|
||||||
|
item_type, item_id, item_title, user_id
|
||||||
|
) VALUES (
|
||||||
|
$1, $2, $3, $4, $5, $6, $7, $8
|
||||||
|
)
|
||||||
|
`,
|
||||||
|
uuid.New(), portfolioID, entry.Timestamp, entry.Action,
|
||||||
|
string(entry.ItemType), entry.ItemID, entry.ItemTitle, entry.UserID,
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRecentActivity retrieves recent activity for a portfolio
|
||||||
|
func (s *Store) GetRecentActivity(ctx context.Context, portfolioID uuid.UUID, limit int) ([]ActivityEntry, error) {
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = 20
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := s.pool.Query(ctx, `
|
||||||
|
SELECT timestamp, action, item_type, item_id, item_title, user_id
|
||||||
|
FROM portfolio_activity
|
||||||
|
WHERE portfolio_id = $1
|
||||||
|
ORDER BY timestamp DESC
|
||||||
|
LIMIT $2
|
||||||
|
`, portfolioID, limit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var activities []ActivityEntry
|
||||||
|
for rows.Next() {
|
||||||
|
var entry ActivityEntry
|
||||||
|
var itemType string
|
||||||
|
err := rows.Scan(
|
||||||
|
&entry.Timestamp, &entry.Action, &itemType,
|
||||||
|
&entry.ItemID, &entry.ItemTitle, &entry.UserID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
entry.ItemType = ItemType(itemType)
|
||||||
|
activities = append(activities, entry)
|
||||||
|
}
|
||||||
|
|
||||||
|
return activities, nil
|
||||||
|
}
|
||||||
238
ai-compliance-sdk/internal/portfolio/store_portfolio.go
Normal file
238
ai-compliance-sdk/internal/portfolio/store_portfolio.go
Normal file
@@ -0,0 +1,238 @@
|
|||||||
|
package portfolio
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Store handles portfolio data persistence
|
||||||
|
type Store struct {
|
||||||
|
pool *pgxpool.Pool
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewStore creates a new portfolio store
|
||||||
|
func NewStore(pool *pgxpool.Pool) *Store {
|
||||||
|
return &Store{pool: pool}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Portfolio CRUD Operations
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
// CreatePortfolio creates a new portfolio
|
||||||
|
func (s *Store) CreatePortfolio(ctx context.Context, p *Portfolio) error {
|
||||||
|
p.ID = uuid.New()
|
||||||
|
p.CreatedAt = time.Now().UTC()
|
||||||
|
p.UpdatedAt = p.CreatedAt
|
||||||
|
if p.Status == "" {
|
||||||
|
p.Status = PortfolioStatusDraft
|
||||||
|
}
|
||||||
|
|
||||||
|
settings, _ := json.Marshal(p.Settings)
|
||||||
|
|
||||||
|
_, err := s.pool.Exec(ctx, `
|
||||||
|
INSERT INTO portfolios (
|
||||||
|
id, tenant_id, namespace_id,
|
||||||
|
name, description, status,
|
||||||
|
department, business_unit, owner, owner_email,
|
||||||
|
total_assessments, total_roadmaps, total_workshops,
|
||||||
|
avg_risk_score, high_risk_count, conditional_count, approved_count,
|
||||||
|
compliance_score, settings,
|
||||||
|
created_at, updated_at, created_by, approved_at, approved_by
|
||||||
|
) VALUES (
|
||||||
|
$1, $2, $3,
|
||||||
|
$4, $5, $6,
|
||||||
|
$7, $8, $9, $10,
|
||||||
|
$11, $12, $13,
|
||||||
|
$14, $15, $16, $17,
|
||||||
|
$18, $19,
|
||||||
|
$20, $21, $22, $23, $24
|
||||||
|
)
|
||||||
|
`,
|
||||||
|
p.ID, p.TenantID, p.NamespaceID,
|
||||||
|
p.Name, p.Description, string(p.Status),
|
||||||
|
p.Department, p.BusinessUnit, p.Owner, p.OwnerEmail,
|
||||||
|
p.TotalAssessments, p.TotalRoadmaps, p.TotalWorkshops,
|
||||||
|
p.AvgRiskScore, p.HighRiskCount, p.ConditionalCount, p.ApprovedCount,
|
||||||
|
p.ComplianceScore, settings,
|
||||||
|
p.CreatedAt, p.UpdatedAt, p.CreatedBy, p.ApprovedAt, p.ApprovedBy,
|
||||||
|
)
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPortfolio retrieves a portfolio by ID
|
||||||
|
func (s *Store) GetPortfolio(ctx context.Context, id uuid.UUID) (*Portfolio, error) {
|
||||||
|
var p Portfolio
|
||||||
|
var status string
|
||||||
|
var settings []byte
|
||||||
|
|
||||||
|
err := s.pool.QueryRow(ctx, `
|
||||||
|
SELECT
|
||||||
|
id, tenant_id, namespace_id,
|
||||||
|
name, description, status,
|
||||||
|
department, business_unit, owner, owner_email,
|
||||||
|
total_assessments, total_roadmaps, total_workshops,
|
||||||
|
avg_risk_score, high_risk_count, conditional_count, approved_count,
|
||||||
|
compliance_score, settings,
|
||||||
|
created_at, updated_at, created_by, approved_at, approved_by
|
||||||
|
FROM portfolios WHERE id = $1
|
||||||
|
`, id).Scan(
|
||||||
|
&p.ID, &p.TenantID, &p.NamespaceID,
|
||||||
|
&p.Name, &p.Description, &status,
|
||||||
|
&p.Department, &p.BusinessUnit, &p.Owner, &p.OwnerEmail,
|
||||||
|
&p.TotalAssessments, &p.TotalRoadmaps, &p.TotalWorkshops,
|
||||||
|
&p.AvgRiskScore, &p.HighRiskCount, &p.ConditionalCount, &p.ApprovedCount,
|
||||||
|
&p.ComplianceScore, &settings,
|
||||||
|
&p.CreatedAt, &p.UpdatedAt, &p.CreatedBy, &p.ApprovedAt, &p.ApprovedBy,
|
||||||
|
)
|
||||||
|
|
||||||
|
if err == pgx.ErrNoRows {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
p.Status = PortfolioStatus(status)
|
||||||
|
json.Unmarshal(settings, &p.Settings)
|
||||||
|
|
||||||
|
return &p, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListPortfolios lists portfolios for a tenant with optional filters
|
||||||
|
func (s *Store) ListPortfolios(ctx context.Context, tenantID uuid.UUID, filters *PortfolioFilters) ([]Portfolio, error) {
|
||||||
|
query := `
|
||||||
|
SELECT
|
||||||
|
id, tenant_id, namespace_id,
|
||||||
|
name, description, status,
|
||||||
|
department, business_unit, owner, owner_email,
|
||||||
|
total_assessments, total_roadmaps, total_workshops,
|
||||||
|
avg_risk_score, high_risk_count, conditional_count, approved_count,
|
||||||
|
compliance_score, settings,
|
||||||
|
created_at, updated_at, created_by, approved_at, approved_by
|
||||||
|
FROM portfolios WHERE tenant_id = $1`
|
||||||
|
|
||||||
|
args := []interface{}{tenantID}
|
||||||
|
argIdx := 2
|
||||||
|
|
||||||
|
if filters != nil {
|
||||||
|
if filters.Status != "" {
|
||||||
|
query += fmt.Sprintf(" AND status = $%d", argIdx)
|
||||||
|
args = append(args, string(filters.Status))
|
||||||
|
argIdx++
|
||||||
|
}
|
||||||
|
if filters.Department != "" {
|
||||||
|
query += fmt.Sprintf(" AND department = $%d", argIdx)
|
||||||
|
args = append(args, filters.Department)
|
||||||
|
argIdx++
|
||||||
|
}
|
||||||
|
if filters.BusinessUnit != "" {
|
||||||
|
query += fmt.Sprintf(" AND business_unit = $%d", argIdx)
|
||||||
|
args = append(args, filters.BusinessUnit)
|
||||||
|
argIdx++
|
||||||
|
}
|
||||||
|
if filters.Owner != "" {
|
||||||
|
query += fmt.Sprintf(" AND owner ILIKE $%d", argIdx)
|
||||||
|
args = append(args, "%"+filters.Owner+"%")
|
||||||
|
argIdx++
|
||||||
|
}
|
||||||
|
if filters.MinRiskScore != nil {
|
||||||
|
query += fmt.Sprintf(" AND avg_risk_score >= $%d", argIdx)
|
||||||
|
args = append(args, *filters.MinRiskScore)
|
||||||
|
argIdx++
|
||||||
|
}
|
||||||
|
if filters.MaxRiskScore != nil {
|
||||||
|
query += fmt.Sprintf(" AND avg_risk_score <= $%d", argIdx)
|
||||||
|
args = append(args, *filters.MaxRiskScore)
|
||||||
|
argIdx++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
query += " ORDER BY updated_at DESC"
|
||||||
|
|
||||||
|
if filters != nil && filters.Limit > 0 {
|
||||||
|
query += fmt.Sprintf(" LIMIT $%d", argIdx)
|
||||||
|
args = append(args, filters.Limit)
|
||||||
|
argIdx++
|
||||||
|
|
||||||
|
if filters.Offset > 0 {
|
||||||
|
query += fmt.Sprintf(" OFFSET $%d", argIdx)
|
||||||
|
args = append(args, filters.Offset)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := s.pool.Query(ctx, query, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var portfolios []Portfolio
|
||||||
|
for rows.Next() {
|
||||||
|
var p Portfolio
|
||||||
|
var status string
|
||||||
|
var settings []byte
|
||||||
|
|
||||||
|
err := rows.Scan(
|
||||||
|
&p.ID, &p.TenantID, &p.NamespaceID,
|
||||||
|
&p.Name, &p.Description, &status,
|
||||||
|
&p.Department, &p.BusinessUnit, &p.Owner, &p.OwnerEmail,
|
||||||
|
&p.TotalAssessments, &p.TotalRoadmaps, &p.TotalWorkshops,
|
||||||
|
&p.AvgRiskScore, &p.HighRiskCount, &p.ConditionalCount, &p.ApprovedCount,
|
||||||
|
&p.ComplianceScore, &settings,
|
||||||
|
&p.CreatedAt, &p.UpdatedAt, &p.CreatedBy, &p.ApprovedAt, &p.ApprovedBy,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
p.Status = PortfolioStatus(status)
|
||||||
|
json.Unmarshal(settings, &p.Settings)
|
||||||
|
|
||||||
|
portfolios = append(portfolios, p)
|
||||||
|
}
|
||||||
|
|
||||||
|
return portfolios, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdatePortfolio updates a portfolio
|
||||||
|
func (s *Store) UpdatePortfolio(ctx context.Context, p *Portfolio) error {
|
||||||
|
p.UpdatedAt = time.Now().UTC()
|
||||||
|
|
||||||
|
settings, _ := json.Marshal(p.Settings)
|
||||||
|
|
||||||
|
_, err := s.pool.Exec(ctx, `
|
||||||
|
UPDATE portfolios SET
|
||||||
|
name = $2, description = $3, status = $4,
|
||||||
|
department = $5, business_unit = $6, owner = $7, owner_email = $8,
|
||||||
|
settings = $9,
|
||||||
|
updated_at = $10, approved_at = $11, approved_by = $12
|
||||||
|
WHERE id = $1
|
||||||
|
`,
|
||||||
|
p.ID, p.Name, p.Description, string(p.Status),
|
||||||
|
p.Department, p.BusinessUnit, p.Owner, p.OwnerEmail,
|
||||||
|
settings,
|
||||||
|
p.UpdatedAt, p.ApprovedAt, p.ApprovedBy,
|
||||||
|
)
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeletePortfolio deletes a portfolio and its items
|
||||||
|
func (s *Store) DeletePortfolio(ctx context.Context, id uuid.UUID) error {
|
||||||
|
// Delete items first
|
||||||
|
_, err := s.pool.Exec(ctx, "DELETE FROM portfolio_items WHERE portfolio_id = $1", id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// Delete portfolio
|
||||||
|
_, err = s.pool.Exec(ctx, "DELETE FROM portfolios WHERE id = $1", id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
@@ -1,757 +0,0 @@
|
|||||||
package roadmap
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/google/uuid"
|
|
||||||
"github.com/jackc/pgx/v5"
|
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Store handles roadmap data persistence
|
|
||||||
type Store struct {
|
|
||||||
pool *pgxpool.Pool
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewStore creates a new roadmap store
|
|
||||||
func NewStore(pool *pgxpool.Pool) *Store {
|
|
||||||
return &Store{pool: pool}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Roadmap CRUD Operations
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
// CreateRoadmap creates a new roadmap
|
|
||||||
func (s *Store) CreateRoadmap(ctx context.Context, r *Roadmap) error {
|
|
||||||
r.ID = uuid.New()
|
|
||||||
r.CreatedAt = time.Now().UTC()
|
|
||||||
r.UpdatedAt = r.CreatedAt
|
|
||||||
if r.Status == "" {
|
|
||||||
r.Status = "draft"
|
|
||||||
}
|
|
||||||
if r.Version == "" {
|
|
||||||
r.Version = "1.0"
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err := s.pool.Exec(ctx, `
|
|
||||||
INSERT INTO roadmaps (
|
|
||||||
id, tenant_id, namespace_id, title, description, version,
|
|
||||||
assessment_id, portfolio_id, status,
|
|
||||||
total_items, completed_items, progress,
|
|
||||||
start_date, target_date,
|
|
||||||
created_at, updated_at, created_by
|
|
||||||
) VALUES (
|
|
||||||
$1, $2, $3, $4, $5, $6,
|
|
||||||
$7, $8, $9,
|
|
||||||
$10, $11, $12,
|
|
||||||
$13, $14,
|
|
||||||
$15, $16, $17
|
|
||||||
)
|
|
||||||
`,
|
|
||||||
r.ID, r.TenantID, r.NamespaceID, r.Title, r.Description, r.Version,
|
|
||||||
r.AssessmentID, r.PortfolioID, r.Status,
|
|
||||||
r.TotalItems, r.CompletedItems, r.Progress,
|
|
||||||
r.StartDate, r.TargetDate,
|
|
||||||
r.CreatedAt, r.UpdatedAt, r.CreatedBy,
|
|
||||||
)
|
|
||||||
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetRoadmap retrieves a roadmap by ID
|
|
||||||
func (s *Store) GetRoadmap(ctx context.Context, id uuid.UUID) (*Roadmap, error) {
|
|
||||||
var r Roadmap
|
|
||||||
|
|
||||||
err := s.pool.QueryRow(ctx, `
|
|
||||||
SELECT
|
|
||||||
id, tenant_id, namespace_id, title, description, version,
|
|
||||||
assessment_id, portfolio_id, status,
|
|
||||||
total_items, completed_items, progress,
|
|
||||||
start_date, target_date,
|
|
||||||
created_at, updated_at, created_by
|
|
||||||
FROM roadmaps WHERE id = $1
|
|
||||||
`, id).Scan(
|
|
||||||
&r.ID, &r.TenantID, &r.NamespaceID, &r.Title, &r.Description, &r.Version,
|
|
||||||
&r.AssessmentID, &r.PortfolioID, &r.Status,
|
|
||||||
&r.TotalItems, &r.CompletedItems, &r.Progress,
|
|
||||||
&r.StartDate, &r.TargetDate,
|
|
||||||
&r.CreatedAt, &r.UpdatedAt, &r.CreatedBy,
|
|
||||||
)
|
|
||||||
|
|
||||||
if err == pgx.ErrNoRows {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return &r, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ListRoadmaps lists roadmaps for a tenant with optional filters
|
|
||||||
func (s *Store) ListRoadmaps(ctx context.Context, tenantID uuid.UUID, filters *RoadmapFilters) ([]Roadmap, error) {
|
|
||||||
query := `
|
|
||||||
SELECT
|
|
||||||
id, tenant_id, namespace_id, title, description, version,
|
|
||||||
assessment_id, portfolio_id, status,
|
|
||||||
total_items, completed_items, progress,
|
|
||||||
start_date, target_date,
|
|
||||||
created_at, updated_at, created_by
|
|
||||||
FROM roadmaps WHERE tenant_id = $1`
|
|
||||||
|
|
||||||
args := []interface{}{tenantID}
|
|
||||||
argIdx := 2
|
|
||||||
|
|
||||||
if filters != nil {
|
|
||||||
if filters.Status != "" {
|
|
||||||
query += fmt.Sprintf(" AND status = $%d", argIdx)
|
|
||||||
args = append(args, filters.Status)
|
|
||||||
argIdx++
|
|
||||||
}
|
|
||||||
if filters.AssessmentID != nil {
|
|
||||||
query += fmt.Sprintf(" AND assessment_id = $%d", argIdx)
|
|
||||||
args = append(args, *filters.AssessmentID)
|
|
||||||
argIdx++
|
|
||||||
}
|
|
||||||
if filters.PortfolioID != nil {
|
|
||||||
query += fmt.Sprintf(" AND portfolio_id = $%d", argIdx)
|
|
||||||
args = append(args, *filters.PortfolioID)
|
|
||||||
argIdx++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
query += " ORDER BY created_at DESC"
|
|
||||||
|
|
||||||
if filters != nil && filters.Limit > 0 {
|
|
||||||
query += fmt.Sprintf(" LIMIT $%d", argIdx)
|
|
||||||
args = append(args, filters.Limit)
|
|
||||||
argIdx++
|
|
||||||
|
|
||||||
if filters.Offset > 0 {
|
|
||||||
query += fmt.Sprintf(" OFFSET $%d", argIdx)
|
|
||||||
args = append(args, filters.Offset)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
rows, err := s.pool.Query(ctx, query, args...)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
var roadmaps []Roadmap
|
|
||||||
for rows.Next() {
|
|
||||||
var r Roadmap
|
|
||||||
err := rows.Scan(
|
|
||||||
&r.ID, &r.TenantID, &r.NamespaceID, &r.Title, &r.Description, &r.Version,
|
|
||||||
&r.AssessmentID, &r.PortfolioID, &r.Status,
|
|
||||||
&r.TotalItems, &r.CompletedItems, &r.Progress,
|
|
||||||
&r.StartDate, &r.TargetDate,
|
|
||||||
&r.CreatedAt, &r.UpdatedAt, &r.CreatedBy,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
roadmaps = append(roadmaps, r)
|
|
||||||
}
|
|
||||||
|
|
||||||
return roadmaps, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// UpdateRoadmap updates a roadmap
|
|
||||||
func (s *Store) UpdateRoadmap(ctx context.Context, r *Roadmap) error {
|
|
||||||
r.UpdatedAt = time.Now().UTC()
|
|
||||||
|
|
||||||
_, err := s.pool.Exec(ctx, `
|
|
||||||
UPDATE roadmaps SET
|
|
||||||
title = $2, description = $3, version = $4,
|
|
||||||
assessment_id = $5, portfolio_id = $6, status = $7,
|
|
||||||
total_items = $8, completed_items = $9, progress = $10,
|
|
||||||
start_date = $11, target_date = $12,
|
|
||||||
updated_at = $13
|
|
||||||
WHERE id = $1
|
|
||||||
`,
|
|
||||||
r.ID, r.Title, r.Description, r.Version,
|
|
||||||
r.AssessmentID, r.PortfolioID, r.Status,
|
|
||||||
r.TotalItems, r.CompletedItems, r.Progress,
|
|
||||||
r.StartDate, r.TargetDate,
|
|
||||||
r.UpdatedAt,
|
|
||||||
)
|
|
||||||
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// DeleteRoadmap deletes a roadmap and its items
|
|
||||||
func (s *Store) DeleteRoadmap(ctx context.Context, id uuid.UUID) error {
|
|
||||||
// Delete items first
|
|
||||||
_, err := s.pool.Exec(ctx, "DELETE FROM roadmap_items WHERE roadmap_id = $1", id)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete roadmap
|
|
||||||
_, err = s.pool.Exec(ctx, "DELETE FROM roadmaps WHERE id = $1", id)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// RoadmapItem CRUD Operations
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
// CreateItem creates a new roadmap item
|
|
||||||
func (s *Store) CreateItem(ctx context.Context, item *RoadmapItem) error {
|
|
||||||
item.ID = uuid.New()
|
|
||||||
item.CreatedAt = time.Now().UTC()
|
|
||||||
item.UpdatedAt = item.CreatedAt
|
|
||||||
if item.Status == "" {
|
|
||||||
item.Status = ItemStatusPlanned
|
|
||||||
}
|
|
||||||
if item.Priority == "" {
|
|
||||||
item.Priority = ItemPriorityMedium
|
|
||||||
}
|
|
||||||
if item.Category == "" {
|
|
||||||
item.Category = ItemCategoryTechnical
|
|
||||||
}
|
|
||||||
|
|
||||||
dependsOn, _ := json.Marshal(item.DependsOn)
|
|
||||||
blockedBy, _ := json.Marshal(item.BlockedBy)
|
|
||||||
evidenceReq, _ := json.Marshal(item.EvidenceRequired)
|
|
||||||
evidenceProv, _ := json.Marshal(item.EvidenceProvided)
|
|
||||||
|
|
||||||
_, err := s.pool.Exec(ctx, `
|
|
||||||
INSERT INTO roadmap_items (
|
|
||||||
id, roadmap_id, title, description, category, priority, status,
|
|
||||||
control_id, regulation_ref, gap_id,
|
|
||||||
effort_days, effort_hours, estimated_cost,
|
|
||||||
assignee_id, assignee_name, department,
|
|
||||||
planned_start, planned_end, actual_start, actual_end,
|
|
||||||
depends_on, blocked_by,
|
|
||||||
evidence_required, evidence_provided,
|
|
||||||
notes, risk_notes,
|
|
||||||
source_row, source_file, sort_order,
|
|
||||||
created_at, updated_at
|
|
||||||
) VALUES (
|
|
||||||
$1, $2, $3, $4, $5, $6, $7,
|
|
||||||
$8, $9, $10,
|
|
||||||
$11, $12, $13,
|
|
||||||
$14, $15, $16,
|
|
||||||
$17, $18, $19, $20,
|
|
||||||
$21, $22,
|
|
||||||
$23, $24,
|
|
||||||
$25, $26,
|
|
||||||
$27, $28, $29,
|
|
||||||
$30, $31
|
|
||||||
)
|
|
||||||
`,
|
|
||||||
item.ID, item.RoadmapID, item.Title, item.Description, string(item.Category), string(item.Priority), string(item.Status),
|
|
||||||
item.ControlID, item.RegulationRef, item.GapID,
|
|
||||||
item.EffortDays, item.EffortHours, item.EstimatedCost,
|
|
||||||
item.AssigneeID, item.AssigneeName, item.Department,
|
|
||||||
item.PlannedStart, item.PlannedEnd, item.ActualStart, item.ActualEnd,
|
|
||||||
dependsOn, blockedBy,
|
|
||||||
evidenceReq, evidenceProv,
|
|
||||||
item.Notes, item.RiskNotes,
|
|
||||||
item.SourceRow, item.SourceFile, item.SortOrder,
|
|
||||||
item.CreatedAt, item.UpdatedAt,
|
|
||||||
)
|
|
||||||
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetItem retrieves a roadmap item by ID
|
|
||||||
func (s *Store) GetItem(ctx context.Context, id uuid.UUID) (*RoadmapItem, error) {
|
|
||||||
var item RoadmapItem
|
|
||||||
var category, priority, status string
|
|
||||||
var dependsOn, blockedBy, evidenceReq, evidenceProv []byte
|
|
||||||
|
|
||||||
err := s.pool.QueryRow(ctx, `
|
|
||||||
SELECT
|
|
||||||
id, roadmap_id, title, description, category, priority, status,
|
|
||||||
control_id, regulation_ref, gap_id,
|
|
||||||
effort_days, effort_hours, estimated_cost,
|
|
||||||
assignee_id, assignee_name, department,
|
|
||||||
planned_start, planned_end, actual_start, actual_end,
|
|
||||||
depends_on, blocked_by,
|
|
||||||
evidence_required, evidence_provided,
|
|
||||||
notes, risk_notes,
|
|
||||||
source_row, source_file, sort_order,
|
|
||||||
created_at, updated_at
|
|
||||||
FROM roadmap_items WHERE id = $1
|
|
||||||
`, id).Scan(
|
|
||||||
&item.ID, &item.RoadmapID, &item.Title, &item.Description, &category, &priority, &status,
|
|
||||||
&item.ControlID, &item.RegulationRef, &item.GapID,
|
|
||||||
&item.EffortDays, &item.EffortHours, &item.EstimatedCost,
|
|
||||||
&item.AssigneeID, &item.AssigneeName, &item.Department,
|
|
||||||
&item.PlannedStart, &item.PlannedEnd, &item.ActualStart, &item.ActualEnd,
|
|
||||||
&dependsOn, &blockedBy,
|
|
||||||
&evidenceReq, &evidenceProv,
|
|
||||||
&item.Notes, &item.RiskNotes,
|
|
||||||
&item.SourceRow, &item.SourceFile, &item.SortOrder,
|
|
||||||
&item.CreatedAt, &item.UpdatedAt,
|
|
||||||
)
|
|
||||||
|
|
||||||
if err == pgx.ErrNoRows {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
item.Category = ItemCategory(category)
|
|
||||||
item.Priority = ItemPriority(priority)
|
|
||||||
item.Status = ItemStatus(status)
|
|
||||||
json.Unmarshal(dependsOn, &item.DependsOn)
|
|
||||||
json.Unmarshal(blockedBy, &item.BlockedBy)
|
|
||||||
json.Unmarshal(evidenceReq, &item.EvidenceRequired)
|
|
||||||
json.Unmarshal(evidenceProv, &item.EvidenceProvided)
|
|
||||||
|
|
||||||
return &item, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ListItems lists items for a roadmap with optional filters
|
|
||||||
func (s *Store) ListItems(ctx context.Context, roadmapID uuid.UUID, filters *RoadmapItemFilters) ([]RoadmapItem, error) {
|
|
||||||
query := `
|
|
||||||
SELECT
|
|
||||||
id, roadmap_id, title, description, category, priority, status,
|
|
||||||
control_id, regulation_ref, gap_id,
|
|
||||||
effort_days, effort_hours, estimated_cost,
|
|
||||||
assignee_id, assignee_name, department,
|
|
||||||
planned_start, planned_end, actual_start, actual_end,
|
|
||||||
depends_on, blocked_by,
|
|
||||||
evidence_required, evidence_provided,
|
|
||||||
notes, risk_notes,
|
|
||||||
source_row, source_file, sort_order,
|
|
||||||
created_at, updated_at
|
|
||||||
FROM roadmap_items WHERE roadmap_id = $1`
|
|
||||||
|
|
||||||
args := []interface{}{roadmapID}
|
|
||||||
argIdx := 2
|
|
||||||
|
|
||||||
if filters != nil {
|
|
||||||
if filters.Status != "" {
|
|
||||||
query += fmt.Sprintf(" AND status = $%d", argIdx)
|
|
||||||
args = append(args, string(filters.Status))
|
|
||||||
argIdx++
|
|
||||||
}
|
|
||||||
if filters.Priority != "" {
|
|
||||||
query += fmt.Sprintf(" AND priority = $%d", argIdx)
|
|
||||||
args = append(args, string(filters.Priority))
|
|
||||||
argIdx++
|
|
||||||
}
|
|
||||||
if filters.Category != "" {
|
|
||||||
query += fmt.Sprintf(" AND category = $%d", argIdx)
|
|
||||||
args = append(args, string(filters.Category))
|
|
||||||
argIdx++
|
|
||||||
}
|
|
||||||
if filters.AssigneeID != nil {
|
|
||||||
query += fmt.Sprintf(" AND assignee_id = $%d", argIdx)
|
|
||||||
args = append(args, *filters.AssigneeID)
|
|
||||||
argIdx++
|
|
||||||
}
|
|
||||||
if filters.ControlID != "" {
|
|
||||||
query += fmt.Sprintf(" AND control_id = $%d", argIdx)
|
|
||||||
args = append(args, filters.ControlID)
|
|
||||||
argIdx++
|
|
||||||
}
|
|
||||||
if filters.SearchQuery != "" {
|
|
||||||
query += fmt.Sprintf(" AND (title ILIKE $%d OR description ILIKE $%d)", argIdx, argIdx)
|
|
||||||
args = append(args, "%"+filters.SearchQuery+"%")
|
|
||||||
argIdx++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
query += " ORDER BY sort_order ASC, priority ASC, created_at ASC"
|
|
||||||
|
|
||||||
if filters != nil && filters.Limit > 0 {
|
|
||||||
query += fmt.Sprintf(" LIMIT $%d", argIdx)
|
|
||||||
args = append(args, filters.Limit)
|
|
||||||
argIdx++
|
|
||||||
|
|
||||||
if filters.Offset > 0 {
|
|
||||||
query += fmt.Sprintf(" OFFSET $%d", argIdx)
|
|
||||||
args = append(args, filters.Offset)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
rows, err := s.pool.Query(ctx, query, args...)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
var items []RoadmapItem
|
|
||||||
for rows.Next() {
|
|
||||||
var item RoadmapItem
|
|
||||||
var category, priority, status string
|
|
||||||
var dependsOn, blockedBy, evidenceReq, evidenceProv []byte
|
|
||||||
|
|
||||||
err := rows.Scan(
|
|
||||||
&item.ID, &item.RoadmapID, &item.Title, &item.Description, &category, &priority, &status,
|
|
||||||
&item.ControlID, &item.RegulationRef, &item.GapID,
|
|
||||||
&item.EffortDays, &item.EffortHours, &item.EstimatedCost,
|
|
||||||
&item.AssigneeID, &item.AssigneeName, &item.Department,
|
|
||||||
&item.PlannedStart, &item.PlannedEnd, &item.ActualStart, &item.ActualEnd,
|
|
||||||
&dependsOn, &blockedBy,
|
|
||||||
&evidenceReq, &evidenceProv,
|
|
||||||
&item.Notes, &item.RiskNotes,
|
|
||||||
&item.SourceRow, &item.SourceFile, &item.SortOrder,
|
|
||||||
&item.CreatedAt, &item.UpdatedAt,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
item.Category = ItemCategory(category)
|
|
||||||
item.Priority = ItemPriority(priority)
|
|
||||||
item.Status = ItemStatus(status)
|
|
||||||
json.Unmarshal(dependsOn, &item.DependsOn)
|
|
||||||
json.Unmarshal(blockedBy, &item.BlockedBy)
|
|
||||||
json.Unmarshal(evidenceReq, &item.EvidenceRequired)
|
|
||||||
json.Unmarshal(evidenceProv, &item.EvidenceProvided)
|
|
||||||
|
|
||||||
items = append(items, item)
|
|
||||||
}
|
|
||||||
|
|
||||||
return items, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// UpdateItem updates a roadmap item
|
|
||||||
func (s *Store) UpdateItem(ctx context.Context, item *RoadmapItem) error {
|
|
||||||
item.UpdatedAt = time.Now().UTC()
|
|
||||||
|
|
||||||
dependsOn, _ := json.Marshal(item.DependsOn)
|
|
||||||
blockedBy, _ := json.Marshal(item.BlockedBy)
|
|
||||||
evidenceReq, _ := json.Marshal(item.EvidenceRequired)
|
|
||||||
evidenceProv, _ := json.Marshal(item.EvidenceProvided)
|
|
||||||
|
|
||||||
_, err := s.pool.Exec(ctx, `
|
|
||||||
UPDATE roadmap_items SET
|
|
||||||
title = $2, description = $3, category = $4, priority = $5, status = $6,
|
|
||||||
control_id = $7, regulation_ref = $8, gap_id = $9,
|
|
||||||
effort_days = $10, effort_hours = $11, estimated_cost = $12,
|
|
||||||
assignee_id = $13, assignee_name = $14, department = $15,
|
|
||||||
planned_start = $16, planned_end = $17, actual_start = $18, actual_end = $19,
|
|
||||||
depends_on = $20, blocked_by = $21,
|
|
||||||
evidence_required = $22, evidence_provided = $23,
|
|
||||||
notes = $24, risk_notes = $25,
|
|
||||||
sort_order = $26, updated_at = $27
|
|
||||||
WHERE id = $1
|
|
||||||
`,
|
|
||||||
item.ID, item.Title, item.Description, string(item.Category), string(item.Priority), string(item.Status),
|
|
||||||
item.ControlID, item.RegulationRef, item.GapID,
|
|
||||||
item.EffortDays, item.EffortHours, item.EstimatedCost,
|
|
||||||
item.AssigneeID, item.AssigneeName, item.Department,
|
|
||||||
item.PlannedStart, item.PlannedEnd, item.ActualStart, item.ActualEnd,
|
|
||||||
dependsOn, blockedBy,
|
|
||||||
evidenceReq, evidenceProv,
|
|
||||||
item.Notes, item.RiskNotes,
|
|
||||||
item.SortOrder, item.UpdatedAt,
|
|
||||||
)
|
|
||||||
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// DeleteItem deletes a roadmap item
|
|
||||||
func (s *Store) DeleteItem(ctx context.Context, id uuid.UUID) error {
|
|
||||||
_, err := s.pool.Exec(ctx, "DELETE FROM roadmap_items WHERE id = $1", id)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// BulkCreateItems creates multiple items in a transaction
|
|
||||||
func (s *Store) BulkCreateItems(ctx context.Context, items []RoadmapItem) error {
|
|
||||||
tx, err := s.pool.Begin(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer tx.Rollback(ctx)
|
|
||||||
|
|
||||||
for i := range items {
|
|
||||||
item := &items[i]
|
|
||||||
item.ID = uuid.New()
|
|
||||||
item.CreatedAt = time.Now().UTC()
|
|
||||||
item.UpdatedAt = item.CreatedAt
|
|
||||||
|
|
||||||
dependsOn, _ := json.Marshal(item.DependsOn)
|
|
||||||
blockedBy, _ := json.Marshal(item.BlockedBy)
|
|
||||||
evidenceReq, _ := json.Marshal(item.EvidenceRequired)
|
|
||||||
evidenceProv, _ := json.Marshal(item.EvidenceProvided)
|
|
||||||
|
|
||||||
_, err := tx.Exec(ctx, `
|
|
||||||
INSERT INTO roadmap_items (
|
|
||||||
id, roadmap_id, title, description, category, priority, status,
|
|
||||||
control_id, regulation_ref, gap_id,
|
|
||||||
effort_days, effort_hours, estimated_cost,
|
|
||||||
assignee_id, assignee_name, department,
|
|
||||||
planned_start, planned_end, actual_start, actual_end,
|
|
||||||
depends_on, blocked_by,
|
|
||||||
evidence_required, evidence_provided,
|
|
||||||
notes, risk_notes,
|
|
||||||
source_row, source_file, sort_order,
|
|
||||||
created_at, updated_at
|
|
||||||
) VALUES (
|
|
||||||
$1, $2, $3, $4, $5, $6, $7,
|
|
||||||
$8, $9, $10,
|
|
||||||
$11, $12, $13,
|
|
||||||
$14, $15, $16,
|
|
||||||
$17, $18, $19, $20,
|
|
||||||
$21, $22,
|
|
||||||
$23, $24,
|
|
||||||
$25, $26,
|
|
||||||
$27, $28, $29,
|
|
||||||
$30, $31
|
|
||||||
)
|
|
||||||
`,
|
|
||||||
item.ID, item.RoadmapID, item.Title, item.Description, string(item.Category), string(item.Priority), string(item.Status),
|
|
||||||
item.ControlID, item.RegulationRef, item.GapID,
|
|
||||||
item.EffortDays, item.EffortHours, item.EstimatedCost,
|
|
||||||
item.AssigneeID, item.AssigneeName, item.Department,
|
|
||||||
item.PlannedStart, item.PlannedEnd, item.ActualStart, item.ActualEnd,
|
|
||||||
dependsOn, blockedBy,
|
|
||||||
evidenceReq, evidenceProv,
|
|
||||||
item.Notes, item.RiskNotes,
|
|
||||||
item.SourceRow, item.SourceFile, item.SortOrder,
|
|
||||||
item.CreatedAt, item.UpdatedAt,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return tx.Commit(ctx)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Import Job Operations
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
// CreateImportJob creates a new import job
|
|
||||||
func (s *Store) CreateImportJob(ctx context.Context, job *ImportJob) error {
|
|
||||||
job.ID = uuid.New()
|
|
||||||
job.CreatedAt = time.Now().UTC()
|
|
||||||
job.UpdatedAt = job.CreatedAt
|
|
||||||
if job.Status == "" {
|
|
||||||
job.Status = "pending"
|
|
||||||
}
|
|
||||||
|
|
||||||
parsedItems, _ := json.Marshal(job.ParsedItems)
|
|
||||||
|
|
||||||
_, err := s.pool.Exec(ctx, `
|
|
||||||
INSERT INTO roadmap_import_jobs (
|
|
||||||
id, tenant_id, roadmap_id,
|
|
||||||
filename, format, file_size, content_type,
|
|
||||||
status, error_message,
|
|
||||||
total_rows, valid_rows, invalid_rows, imported_items,
|
|
||||||
parsed_items,
|
|
||||||
created_at, updated_at, completed_at, created_by
|
|
||||||
) VALUES (
|
|
||||||
$1, $2, $3,
|
|
||||||
$4, $5, $6, $7,
|
|
||||||
$8, $9,
|
|
||||||
$10, $11, $12, $13,
|
|
||||||
$14,
|
|
||||||
$15, $16, $17, $18
|
|
||||||
)
|
|
||||||
`,
|
|
||||||
job.ID, job.TenantID, job.RoadmapID,
|
|
||||||
job.Filename, string(job.Format), job.FileSize, job.ContentType,
|
|
||||||
job.Status, job.ErrorMessage,
|
|
||||||
job.TotalRows, job.ValidRows, job.InvalidRows, job.ImportedItems,
|
|
||||||
parsedItems,
|
|
||||||
job.CreatedAt, job.UpdatedAt, job.CompletedAt, job.CreatedBy,
|
|
||||||
)
|
|
||||||
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetImportJob retrieves an import job by ID
|
|
||||||
func (s *Store) GetImportJob(ctx context.Context, id uuid.UUID) (*ImportJob, error) {
|
|
||||||
var job ImportJob
|
|
||||||
var format string
|
|
||||||
var parsedItems []byte
|
|
||||||
|
|
||||||
err := s.pool.QueryRow(ctx, `
|
|
||||||
SELECT
|
|
||||||
id, tenant_id, roadmap_id,
|
|
||||||
filename, format, file_size, content_type,
|
|
||||||
status, error_message,
|
|
||||||
total_rows, valid_rows, invalid_rows, imported_items,
|
|
||||||
parsed_items,
|
|
||||||
created_at, updated_at, completed_at, created_by
|
|
||||||
FROM roadmap_import_jobs WHERE id = $1
|
|
||||||
`, id).Scan(
|
|
||||||
&job.ID, &job.TenantID, &job.RoadmapID,
|
|
||||||
&job.Filename, &format, &job.FileSize, &job.ContentType,
|
|
||||||
&job.Status, &job.ErrorMessage,
|
|
||||||
&job.TotalRows, &job.ValidRows, &job.InvalidRows, &job.ImportedItems,
|
|
||||||
&parsedItems,
|
|
||||||
&job.CreatedAt, &job.UpdatedAt, &job.CompletedAt, &job.CreatedBy,
|
|
||||||
)
|
|
||||||
|
|
||||||
if err == pgx.ErrNoRows {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
job.Format = ImportFormat(format)
|
|
||||||
json.Unmarshal(parsedItems, &job.ParsedItems)
|
|
||||||
|
|
||||||
return &job, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// UpdateImportJob updates an import job
|
|
||||||
func (s *Store) UpdateImportJob(ctx context.Context, job *ImportJob) error {
|
|
||||||
job.UpdatedAt = time.Now().UTC()
|
|
||||||
|
|
||||||
parsedItems, _ := json.Marshal(job.ParsedItems)
|
|
||||||
|
|
||||||
_, err := s.pool.Exec(ctx, `
|
|
||||||
UPDATE roadmap_import_jobs SET
|
|
||||||
roadmap_id = $2,
|
|
||||||
status = $3, error_message = $4,
|
|
||||||
total_rows = $5, valid_rows = $6, invalid_rows = $7, imported_items = $8,
|
|
||||||
parsed_items = $9,
|
|
||||||
updated_at = $10, completed_at = $11
|
|
||||||
WHERE id = $1
|
|
||||||
`,
|
|
||||||
job.ID, job.RoadmapID,
|
|
||||||
job.Status, job.ErrorMessage,
|
|
||||||
job.TotalRows, job.ValidRows, job.InvalidRows, job.ImportedItems,
|
|
||||||
parsedItems,
|
|
||||||
job.UpdatedAt, job.CompletedAt,
|
|
||||||
)
|
|
||||||
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Statistics
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
// GetRoadmapStats returns statistics for a roadmap
|
|
||||||
func (s *Store) GetRoadmapStats(ctx context.Context, roadmapID uuid.UUID) (*RoadmapStats, error) {
|
|
||||||
stats := &RoadmapStats{
|
|
||||||
ByStatus: make(map[string]int),
|
|
||||||
ByPriority: make(map[string]int),
|
|
||||||
ByCategory: make(map[string]int),
|
|
||||||
ByDepartment: make(map[string]int),
|
|
||||||
}
|
|
||||||
|
|
||||||
// Total count
|
|
||||||
s.pool.QueryRow(ctx,
|
|
||||||
"SELECT COUNT(*) FROM roadmap_items WHERE roadmap_id = $1",
|
|
||||||
roadmapID).Scan(&stats.TotalItems)
|
|
||||||
|
|
||||||
// By status
|
|
||||||
rows, err := s.pool.Query(ctx,
|
|
||||||
"SELECT status, COUNT(*) FROM roadmap_items WHERE roadmap_id = $1 GROUP BY status",
|
|
||||||
roadmapID)
|
|
||||||
if err == nil {
|
|
||||||
defer rows.Close()
|
|
||||||
for rows.Next() {
|
|
||||||
var status string
|
|
||||||
var count int
|
|
||||||
rows.Scan(&status, &count)
|
|
||||||
stats.ByStatus[status] = count
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// By priority
|
|
||||||
rows, err = s.pool.Query(ctx,
|
|
||||||
"SELECT priority, COUNT(*) FROM roadmap_items WHERE roadmap_id = $1 GROUP BY priority",
|
|
||||||
roadmapID)
|
|
||||||
if err == nil {
|
|
||||||
defer rows.Close()
|
|
||||||
for rows.Next() {
|
|
||||||
var priority string
|
|
||||||
var count int
|
|
||||||
rows.Scan(&priority, &count)
|
|
||||||
stats.ByPriority[priority] = count
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// By category
|
|
||||||
rows, err = s.pool.Query(ctx,
|
|
||||||
"SELECT category, COUNT(*) FROM roadmap_items WHERE roadmap_id = $1 GROUP BY category",
|
|
||||||
roadmapID)
|
|
||||||
if err == nil {
|
|
||||||
defer rows.Close()
|
|
||||||
for rows.Next() {
|
|
||||||
var category string
|
|
||||||
var count int
|
|
||||||
rows.Scan(&category, &count)
|
|
||||||
stats.ByCategory[category] = count
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// By department
|
|
||||||
rows, err = s.pool.Query(ctx,
|
|
||||||
"SELECT COALESCE(department, 'Unassigned'), COUNT(*) FROM roadmap_items WHERE roadmap_id = $1 GROUP BY department",
|
|
||||||
roadmapID)
|
|
||||||
if err == nil {
|
|
||||||
defer rows.Close()
|
|
||||||
for rows.Next() {
|
|
||||||
var dept string
|
|
||||||
var count int
|
|
||||||
rows.Scan(&dept, &count)
|
|
||||||
stats.ByDepartment[dept] = count
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Overdue items
|
|
||||||
s.pool.QueryRow(ctx,
|
|
||||||
"SELECT COUNT(*) FROM roadmap_items WHERE roadmap_id = $1 AND planned_end < NOW() AND status NOT IN ('COMPLETED', 'DEFERRED')",
|
|
||||||
roadmapID).Scan(&stats.OverdueItems)
|
|
||||||
|
|
||||||
// Upcoming items (next 7 days)
|
|
||||||
s.pool.QueryRow(ctx,
|
|
||||||
"SELECT COUNT(*) FROM roadmap_items WHERE roadmap_id = $1 AND planned_end BETWEEN NOW() AND NOW() + INTERVAL '7 days' AND status NOT IN ('COMPLETED', 'DEFERRED')",
|
|
||||||
roadmapID).Scan(&stats.UpcomingItems)
|
|
||||||
|
|
||||||
// Total effort
|
|
||||||
s.pool.QueryRow(ctx,
|
|
||||||
"SELECT COALESCE(SUM(effort_days), 0) FROM roadmap_items WHERE roadmap_id = $1",
|
|
||||||
roadmapID).Scan(&stats.TotalEffortDays)
|
|
||||||
|
|
||||||
// Progress
|
|
||||||
completedCount := stats.ByStatus[string(ItemStatusCompleted)]
|
|
||||||
if stats.TotalItems > 0 {
|
|
||||||
stats.Progress = (completedCount * 100) / stats.TotalItems
|
|
||||||
}
|
|
||||||
|
|
||||||
return stats, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// UpdateRoadmapProgress recalculates and updates roadmap progress
|
|
||||||
func (s *Store) UpdateRoadmapProgress(ctx context.Context, roadmapID uuid.UUID) error {
|
|
||||||
var total, completed int
|
|
||||||
|
|
||||||
s.pool.QueryRow(ctx,
|
|
||||||
"SELECT COUNT(*) FROM roadmap_items WHERE roadmap_id = $1",
|
|
||||||
roadmapID).Scan(&total)
|
|
||||||
|
|
||||||
s.pool.QueryRow(ctx,
|
|
||||||
"SELECT COUNT(*) FROM roadmap_items WHERE roadmap_id = $1 AND status = 'COMPLETED'",
|
|
||||||
roadmapID).Scan(&completed)
|
|
||||||
|
|
||||||
progress := 0
|
|
||||||
if total > 0 {
|
|
||||||
progress = (completed * 100) / total
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err := s.pool.Exec(ctx, `
|
|
||||||
UPDATE roadmaps SET
|
|
||||||
total_items = $2,
|
|
||||||
completed_items = $3,
|
|
||||||
progress = $4,
|
|
||||||
updated_at = $5
|
|
||||||
WHERE id = $1
|
|
||||||
`, roadmapID, total, completed, progress, time.Now().UTC())
|
|
||||||
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
213
ai-compliance-sdk/internal/roadmap/store_import.go
Normal file
213
ai-compliance-sdk/internal/roadmap/store_import.go
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
package roadmap
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Import Job Operations
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
// CreateImportJob creates a new import job
|
||||||
|
func (s *Store) CreateImportJob(ctx context.Context, job *ImportJob) error {
|
||||||
|
job.ID = uuid.New()
|
||||||
|
job.CreatedAt = time.Now().UTC()
|
||||||
|
job.UpdatedAt = job.CreatedAt
|
||||||
|
if job.Status == "" {
|
||||||
|
job.Status = "pending"
|
||||||
|
}
|
||||||
|
|
||||||
|
parsedItems, _ := json.Marshal(job.ParsedItems)
|
||||||
|
|
||||||
|
_, err := s.pool.Exec(ctx, `
|
||||||
|
INSERT INTO roadmap_import_jobs (
|
||||||
|
id, tenant_id, roadmap_id,
|
||||||
|
filename, format, file_size, content_type,
|
||||||
|
status, error_message,
|
||||||
|
total_rows, valid_rows, invalid_rows, imported_items,
|
||||||
|
parsed_items,
|
||||||
|
created_at, updated_at, completed_at, created_by
|
||||||
|
) VALUES (
|
||||||
|
$1, $2, $3,
|
||||||
|
$4, $5, $6, $7,
|
||||||
|
$8, $9,
|
||||||
|
$10, $11, $12, $13,
|
||||||
|
$14,
|
||||||
|
$15, $16, $17, $18
|
||||||
|
)
|
||||||
|
`,
|
||||||
|
job.ID, job.TenantID, job.RoadmapID,
|
||||||
|
job.Filename, string(job.Format), job.FileSize, job.ContentType,
|
||||||
|
job.Status, job.ErrorMessage,
|
||||||
|
job.TotalRows, job.ValidRows, job.InvalidRows, job.ImportedItems,
|
||||||
|
parsedItems,
|
||||||
|
job.CreatedAt, job.UpdatedAt, job.CompletedAt, job.CreatedBy,
|
||||||
|
)
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetImportJob retrieves an import job by ID
|
||||||
|
func (s *Store) GetImportJob(ctx context.Context, id uuid.UUID) (*ImportJob, error) {
|
||||||
|
var job ImportJob
|
||||||
|
var format string
|
||||||
|
var parsedItems []byte
|
||||||
|
|
||||||
|
err := s.pool.QueryRow(ctx, `
|
||||||
|
SELECT
|
||||||
|
id, tenant_id, roadmap_id,
|
||||||
|
filename, format, file_size, content_type,
|
||||||
|
status, error_message,
|
||||||
|
total_rows, valid_rows, invalid_rows, imported_items,
|
||||||
|
parsed_items,
|
||||||
|
created_at, updated_at, completed_at, created_by
|
||||||
|
FROM roadmap_import_jobs WHERE id = $1
|
||||||
|
`, id).Scan(
|
||||||
|
&job.ID, &job.TenantID, &job.RoadmapID,
|
||||||
|
&job.Filename, &format, &job.FileSize, &job.ContentType,
|
||||||
|
&job.Status, &job.ErrorMessage,
|
||||||
|
&job.TotalRows, &job.ValidRows, &job.InvalidRows, &job.ImportedItems,
|
||||||
|
&parsedItems,
|
||||||
|
&job.CreatedAt, &job.UpdatedAt, &job.CompletedAt, &job.CreatedBy,
|
||||||
|
)
|
||||||
|
|
||||||
|
if err == pgx.ErrNoRows {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
job.Format = ImportFormat(format)
|
||||||
|
json.Unmarshal(parsedItems, &job.ParsedItems)
|
||||||
|
|
||||||
|
return &job, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateImportJob updates an import job
|
||||||
|
func (s *Store) UpdateImportJob(ctx context.Context, job *ImportJob) error {
|
||||||
|
job.UpdatedAt = time.Now().UTC()
|
||||||
|
|
||||||
|
parsedItems, _ := json.Marshal(job.ParsedItems)
|
||||||
|
|
||||||
|
_, err := s.pool.Exec(ctx, `
|
||||||
|
UPDATE roadmap_import_jobs SET
|
||||||
|
roadmap_id = $2,
|
||||||
|
status = $3, error_message = $4,
|
||||||
|
total_rows = $5, valid_rows = $6, invalid_rows = $7, imported_items = $8,
|
||||||
|
parsed_items = $9,
|
||||||
|
updated_at = $10, completed_at = $11
|
||||||
|
WHERE id = $1
|
||||||
|
`,
|
||||||
|
job.ID, job.RoadmapID,
|
||||||
|
job.Status, job.ErrorMessage,
|
||||||
|
job.TotalRows, job.ValidRows, job.InvalidRows, job.ImportedItems,
|
||||||
|
parsedItems,
|
||||||
|
job.UpdatedAt, job.CompletedAt,
|
||||||
|
)
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Statistics
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
// GetRoadmapStats returns statistics for a roadmap
|
||||||
|
func (s *Store) GetRoadmapStats(ctx context.Context, roadmapID uuid.UUID) (*RoadmapStats, error) {
|
||||||
|
stats := &RoadmapStats{
|
||||||
|
ByStatus: make(map[string]int),
|
||||||
|
ByPriority: make(map[string]int),
|
||||||
|
ByCategory: make(map[string]int),
|
||||||
|
ByDepartment: make(map[string]int),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Total count
|
||||||
|
s.pool.QueryRow(ctx,
|
||||||
|
"SELECT COUNT(*) FROM roadmap_items WHERE roadmap_id = $1",
|
||||||
|
roadmapID).Scan(&stats.TotalItems)
|
||||||
|
|
||||||
|
// By status
|
||||||
|
rows, err := s.pool.Query(ctx,
|
||||||
|
"SELECT status, COUNT(*) FROM roadmap_items WHERE roadmap_id = $1 GROUP BY status",
|
||||||
|
roadmapID)
|
||||||
|
if err == nil {
|
||||||
|
defer rows.Close()
|
||||||
|
for rows.Next() {
|
||||||
|
var status string
|
||||||
|
var count int
|
||||||
|
rows.Scan(&status, &count)
|
||||||
|
stats.ByStatus[status] = count
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// By priority
|
||||||
|
rows, err = s.pool.Query(ctx,
|
||||||
|
"SELECT priority, COUNT(*) FROM roadmap_items WHERE roadmap_id = $1 GROUP BY priority",
|
||||||
|
roadmapID)
|
||||||
|
if err == nil {
|
||||||
|
defer rows.Close()
|
||||||
|
for rows.Next() {
|
||||||
|
var priority string
|
||||||
|
var count int
|
||||||
|
rows.Scan(&priority, &count)
|
||||||
|
stats.ByPriority[priority] = count
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// By category
|
||||||
|
rows, err = s.pool.Query(ctx,
|
||||||
|
"SELECT category, COUNT(*) FROM roadmap_items WHERE roadmap_id = $1 GROUP BY category",
|
||||||
|
roadmapID)
|
||||||
|
if err == nil {
|
||||||
|
defer rows.Close()
|
||||||
|
for rows.Next() {
|
||||||
|
var category string
|
||||||
|
var count int
|
||||||
|
rows.Scan(&category, &count)
|
||||||
|
stats.ByCategory[category] = count
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// By department
|
||||||
|
rows, err = s.pool.Query(ctx,
|
||||||
|
"SELECT COALESCE(department, 'Unassigned'), COUNT(*) FROM roadmap_items WHERE roadmap_id = $1 GROUP BY department",
|
||||||
|
roadmapID)
|
||||||
|
if err == nil {
|
||||||
|
defer rows.Close()
|
||||||
|
for rows.Next() {
|
||||||
|
var dept string
|
||||||
|
var count int
|
||||||
|
rows.Scan(&dept, &count)
|
||||||
|
stats.ByDepartment[dept] = count
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Overdue items
|
||||||
|
s.pool.QueryRow(ctx,
|
||||||
|
"SELECT COUNT(*) FROM roadmap_items WHERE roadmap_id = $1 AND planned_end < NOW() AND status NOT IN ('COMPLETED', 'DEFERRED')",
|
||||||
|
roadmapID).Scan(&stats.OverdueItems)
|
||||||
|
|
||||||
|
// Upcoming items (next 7 days)
|
||||||
|
s.pool.QueryRow(ctx,
|
||||||
|
"SELECT COUNT(*) FROM roadmap_items WHERE roadmap_id = $1 AND planned_end BETWEEN NOW() AND NOW() + INTERVAL '7 days' AND status NOT IN ('COMPLETED', 'DEFERRED')",
|
||||||
|
roadmapID).Scan(&stats.UpcomingItems)
|
||||||
|
|
||||||
|
// Total effort
|
||||||
|
s.pool.QueryRow(ctx,
|
||||||
|
"SELECT COALESCE(SUM(effort_days), 0) FROM roadmap_items WHERE roadmap_id = $1",
|
||||||
|
roadmapID).Scan(&stats.TotalEffortDays)
|
||||||
|
|
||||||
|
// Progress
|
||||||
|
completedCount := stats.ByStatus[string(ItemStatusCompleted)]
|
||||||
|
if stats.TotalItems > 0 {
|
||||||
|
stats.Progress = (completedCount * 100) / stats.TotalItems
|
||||||
|
}
|
||||||
|
|
||||||
|
return stats, nil
|
||||||
|
}
|
||||||
337
ai-compliance-sdk/internal/roadmap/store_items.go
Normal file
337
ai-compliance-sdk/internal/roadmap/store_items.go
Normal file
@@ -0,0 +1,337 @@
|
|||||||
|
package roadmap
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// RoadmapItem CRUD Operations
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
// CreateItem creates a new roadmap item
|
||||||
|
func (s *Store) CreateItem(ctx context.Context, item *RoadmapItem) error {
|
||||||
|
item.ID = uuid.New()
|
||||||
|
item.CreatedAt = time.Now().UTC()
|
||||||
|
item.UpdatedAt = item.CreatedAt
|
||||||
|
if item.Status == "" {
|
||||||
|
item.Status = ItemStatusPlanned
|
||||||
|
}
|
||||||
|
if item.Priority == "" {
|
||||||
|
item.Priority = ItemPriorityMedium
|
||||||
|
}
|
||||||
|
if item.Category == "" {
|
||||||
|
item.Category = ItemCategoryTechnical
|
||||||
|
}
|
||||||
|
|
||||||
|
dependsOn, _ := json.Marshal(item.DependsOn)
|
||||||
|
blockedBy, _ := json.Marshal(item.BlockedBy)
|
||||||
|
evidenceReq, _ := json.Marshal(item.EvidenceRequired)
|
||||||
|
evidenceProv, _ := json.Marshal(item.EvidenceProvided)
|
||||||
|
|
||||||
|
_, err := s.pool.Exec(ctx, `
|
||||||
|
INSERT INTO roadmap_items (
|
||||||
|
id, roadmap_id, title, description, category, priority, status,
|
||||||
|
control_id, regulation_ref, gap_id,
|
||||||
|
effort_days, effort_hours, estimated_cost,
|
||||||
|
assignee_id, assignee_name, department,
|
||||||
|
planned_start, planned_end, actual_start, actual_end,
|
||||||
|
depends_on, blocked_by,
|
||||||
|
evidence_required, evidence_provided,
|
||||||
|
notes, risk_notes,
|
||||||
|
source_row, source_file, sort_order,
|
||||||
|
created_at, updated_at
|
||||||
|
) VALUES (
|
||||||
|
$1, $2, $3, $4, $5, $6, $7,
|
||||||
|
$8, $9, $10,
|
||||||
|
$11, $12, $13,
|
||||||
|
$14, $15, $16,
|
||||||
|
$17, $18, $19, $20,
|
||||||
|
$21, $22,
|
||||||
|
$23, $24,
|
||||||
|
$25, $26,
|
||||||
|
$27, $28, $29,
|
||||||
|
$30, $31
|
||||||
|
)
|
||||||
|
`,
|
||||||
|
item.ID, item.RoadmapID, item.Title, item.Description, string(item.Category), string(item.Priority), string(item.Status),
|
||||||
|
item.ControlID, item.RegulationRef, item.GapID,
|
||||||
|
item.EffortDays, item.EffortHours, item.EstimatedCost,
|
||||||
|
item.AssigneeID, item.AssigneeName, item.Department,
|
||||||
|
item.PlannedStart, item.PlannedEnd, item.ActualStart, item.ActualEnd,
|
||||||
|
dependsOn, blockedBy,
|
||||||
|
evidenceReq, evidenceProv,
|
||||||
|
item.Notes, item.RiskNotes,
|
||||||
|
item.SourceRow, item.SourceFile, item.SortOrder,
|
||||||
|
item.CreatedAt, item.UpdatedAt,
|
||||||
|
)
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetItem retrieves a roadmap item by ID
|
||||||
|
func (s *Store) GetItem(ctx context.Context, id uuid.UUID) (*RoadmapItem, error) {
|
||||||
|
var item RoadmapItem
|
||||||
|
var category, priority, status string
|
||||||
|
var dependsOn, blockedBy, evidenceReq, evidenceProv []byte
|
||||||
|
|
||||||
|
err := s.pool.QueryRow(ctx, `
|
||||||
|
SELECT
|
||||||
|
id, roadmap_id, title, description, category, priority, status,
|
||||||
|
control_id, regulation_ref, gap_id,
|
||||||
|
effort_days, effort_hours, estimated_cost,
|
||||||
|
assignee_id, assignee_name, department,
|
||||||
|
planned_start, planned_end, actual_start, actual_end,
|
||||||
|
depends_on, blocked_by,
|
||||||
|
evidence_required, evidence_provided,
|
||||||
|
notes, risk_notes,
|
||||||
|
source_row, source_file, sort_order,
|
||||||
|
created_at, updated_at
|
||||||
|
FROM roadmap_items WHERE id = $1
|
||||||
|
`, id).Scan(
|
||||||
|
&item.ID, &item.RoadmapID, &item.Title, &item.Description, &category, &priority, &status,
|
||||||
|
&item.ControlID, &item.RegulationRef, &item.GapID,
|
||||||
|
&item.EffortDays, &item.EffortHours, &item.EstimatedCost,
|
||||||
|
&item.AssigneeID, &item.AssigneeName, &item.Department,
|
||||||
|
&item.PlannedStart, &item.PlannedEnd, &item.ActualStart, &item.ActualEnd,
|
||||||
|
&dependsOn, &blockedBy,
|
||||||
|
&evidenceReq, &evidenceProv,
|
||||||
|
&item.Notes, &item.RiskNotes,
|
||||||
|
&item.SourceRow, &item.SourceFile, &item.SortOrder,
|
||||||
|
&item.CreatedAt, &item.UpdatedAt,
|
||||||
|
)
|
||||||
|
|
||||||
|
if err == pgx.ErrNoRows {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
item.Category = ItemCategory(category)
|
||||||
|
item.Priority = ItemPriority(priority)
|
||||||
|
item.Status = ItemStatus(status)
|
||||||
|
json.Unmarshal(dependsOn, &item.DependsOn)
|
||||||
|
json.Unmarshal(blockedBy, &item.BlockedBy)
|
||||||
|
json.Unmarshal(evidenceReq, &item.EvidenceRequired)
|
||||||
|
json.Unmarshal(evidenceProv, &item.EvidenceProvided)
|
||||||
|
|
||||||
|
return &item, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListItems lists items for a roadmap with optional filters
|
||||||
|
func (s *Store) ListItems(ctx context.Context, roadmapID uuid.UUID, filters *RoadmapItemFilters) ([]RoadmapItem, error) {
|
||||||
|
query := `
|
||||||
|
SELECT
|
||||||
|
id, roadmap_id, title, description, category, priority, status,
|
||||||
|
control_id, regulation_ref, gap_id,
|
||||||
|
effort_days, effort_hours, estimated_cost,
|
||||||
|
assignee_id, assignee_name, department,
|
||||||
|
planned_start, planned_end, actual_start, actual_end,
|
||||||
|
depends_on, blocked_by,
|
||||||
|
evidence_required, evidence_provided,
|
||||||
|
notes, risk_notes,
|
||||||
|
source_row, source_file, sort_order,
|
||||||
|
created_at, updated_at
|
||||||
|
FROM roadmap_items WHERE roadmap_id = $1`
|
||||||
|
|
||||||
|
args := []interface{}{roadmapID}
|
||||||
|
argIdx := 2
|
||||||
|
|
||||||
|
if filters != nil {
|
||||||
|
if filters.Status != "" {
|
||||||
|
query += fmt.Sprintf(" AND status = $%d", argIdx)
|
||||||
|
args = append(args, string(filters.Status))
|
||||||
|
argIdx++
|
||||||
|
}
|
||||||
|
if filters.Priority != "" {
|
||||||
|
query += fmt.Sprintf(" AND priority = $%d", argIdx)
|
||||||
|
args = append(args, string(filters.Priority))
|
||||||
|
argIdx++
|
||||||
|
}
|
||||||
|
if filters.Category != "" {
|
||||||
|
query += fmt.Sprintf(" AND category = $%d", argIdx)
|
||||||
|
args = append(args, string(filters.Category))
|
||||||
|
argIdx++
|
||||||
|
}
|
||||||
|
if filters.AssigneeID != nil {
|
||||||
|
query += fmt.Sprintf(" AND assignee_id = $%d", argIdx)
|
||||||
|
args = append(args, *filters.AssigneeID)
|
||||||
|
argIdx++
|
||||||
|
}
|
||||||
|
if filters.ControlID != "" {
|
||||||
|
query += fmt.Sprintf(" AND control_id = $%d", argIdx)
|
||||||
|
args = append(args, filters.ControlID)
|
||||||
|
argIdx++
|
||||||
|
}
|
||||||
|
if filters.SearchQuery != "" {
|
||||||
|
query += fmt.Sprintf(" AND (title ILIKE $%d OR description ILIKE $%d)", argIdx, argIdx)
|
||||||
|
args = append(args, "%"+filters.SearchQuery+"%")
|
||||||
|
argIdx++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
query += " ORDER BY sort_order ASC, priority ASC, created_at ASC"
|
||||||
|
|
||||||
|
if filters != nil && filters.Limit > 0 {
|
||||||
|
query += fmt.Sprintf(" LIMIT $%d", argIdx)
|
||||||
|
args = append(args, filters.Limit)
|
||||||
|
argIdx++
|
||||||
|
|
||||||
|
if filters.Offset > 0 {
|
||||||
|
query += fmt.Sprintf(" OFFSET $%d", argIdx)
|
||||||
|
args = append(args, filters.Offset)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := s.pool.Query(ctx, query, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var items []RoadmapItem
|
||||||
|
for rows.Next() {
|
||||||
|
var item RoadmapItem
|
||||||
|
var category, priority, status string
|
||||||
|
var dependsOn, blockedBy, evidenceReq, evidenceProv []byte
|
||||||
|
|
||||||
|
err := rows.Scan(
|
||||||
|
&item.ID, &item.RoadmapID, &item.Title, &item.Description, &category, &priority, &status,
|
||||||
|
&item.ControlID, &item.RegulationRef, &item.GapID,
|
||||||
|
&item.EffortDays, &item.EffortHours, &item.EstimatedCost,
|
||||||
|
&item.AssigneeID, &item.AssigneeName, &item.Department,
|
||||||
|
&item.PlannedStart, &item.PlannedEnd, &item.ActualStart, &item.ActualEnd,
|
||||||
|
&dependsOn, &blockedBy,
|
||||||
|
&evidenceReq, &evidenceProv,
|
||||||
|
&item.Notes, &item.RiskNotes,
|
||||||
|
&item.SourceRow, &item.SourceFile, &item.SortOrder,
|
||||||
|
&item.CreatedAt, &item.UpdatedAt,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
item.Category = ItemCategory(category)
|
||||||
|
item.Priority = ItemPriority(priority)
|
||||||
|
item.Status = ItemStatus(status)
|
||||||
|
json.Unmarshal(dependsOn, &item.DependsOn)
|
||||||
|
json.Unmarshal(blockedBy, &item.BlockedBy)
|
||||||
|
json.Unmarshal(evidenceReq, &item.EvidenceRequired)
|
||||||
|
json.Unmarshal(evidenceProv, &item.EvidenceProvided)
|
||||||
|
|
||||||
|
items = append(items, item)
|
||||||
|
}
|
||||||
|
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateItem updates a roadmap item
|
||||||
|
func (s *Store) UpdateItem(ctx context.Context, item *RoadmapItem) error {
|
||||||
|
item.UpdatedAt = time.Now().UTC()
|
||||||
|
|
||||||
|
dependsOn, _ := json.Marshal(item.DependsOn)
|
||||||
|
blockedBy, _ := json.Marshal(item.BlockedBy)
|
||||||
|
evidenceReq, _ := json.Marshal(item.EvidenceRequired)
|
||||||
|
evidenceProv, _ := json.Marshal(item.EvidenceProvided)
|
||||||
|
|
||||||
|
_, err := s.pool.Exec(ctx, `
|
||||||
|
UPDATE roadmap_items SET
|
||||||
|
title = $2, description = $3, category = $4, priority = $5, status = $6,
|
||||||
|
control_id = $7, regulation_ref = $8, gap_id = $9,
|
||||||
|
effort_days = $10, effort_hours = $11, estimated_cost = $12,
|
||||||
|
assignee_id = $13, assignee_name = $14, department = $15,
|
||||||
|
planned_start = $16, planned_end = $17, actual_start = $18, actual_end = $19,
|
||||||
|
depends_on = $20, blocked_by = $21,
|
||||||
|
evidence_required = $22, evidence_provided = $23,
|
||||||
|
notes = $24, risk_notes = $25,
|
||||||
|
sort_order = $26, updated_at = $27
|
||||||
|
WHERE id = $1
|
||||||
|
`,
|
||||||
|
item.ID, item.Title, item.Description, string(item.Category), string(item.Priority), string(item.Status),
|
||||||
|
item.ControlID, item.RegulationRef, item.GapID,
|
||||||
|
item.EffortDays, item.EffortHours, item.EstimatedCost,
|
||||||
|
item.AssigneeID, item.AssigneeName, item.Department,
|
||||||
|
item.PlannedStart, item.PlannedEnd, item.ActualStart, item.ActualEnd,
|
||||||
|
dependsOn, blockedBy,
|
||||||
|
evidenceReq, evidenceProv,
|
||||||
|
item.Notes, item.RiskNotes,
|
||||||
|
item.SortOrder, item.UpdatedAt,
|
||||||
|
)
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteItem deletes a roadmap item
|
||||||
|
func (s *Store) DeleteItem(ctx context.Context, id uuid.UUID) error {
|
||||||
|
_, err := s.pool.Exec(ctx, "DELETE FROM roadmap_items WHERE id = $1", id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// BulkCreateItems creates multiple items in a transaction
|
||||||
|
func (s *Store) BulkCreateItems(ctx context.Context, items []RoadmapItem) error {
|
||||||
|
tx, err := s.pool.Begin(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer tx.Rollback(ctx)
|
||||||
|
|
||||||
|
for i := range items {
|
||||||
|
item := &items[i]
|
||||||
|
item.ID = uuid.New()
|
||||||
|
item.CreatedAt = time.Now().UTC()
|
||||||
|
item.UpdatedAt = item.CreatedAt
|
||||||
|
|
||||||
|
dependsOn, _ := json.Marshal(item.DependsOn)
|
||||||
|
blockedBy, _ := json.Marshal(item.BlockedBy)
|
||||||
|
evidenceReq, _ := json.Marshal(item.EvidenceRequired)
|
||||||
|
evidenceProv, _ := json.Marshal(item.EvidenceProvided)
|
||||||
|
|
||||||
|
_, err := tx.Exec(ctx, `
|
||||||
|
INSERT INTO roadmap_items (
|
||||||
|
id, roadmap_id, title, description, category, priority, status,
|
||||||
|
control_id, regulation_ref, gap_id,
|
||||||
|
effort_days, effort_hours, estimated_cost,
|
||||||
|
assignee_id, assignee_name, department,
|
||||||
|
planned_start, planned_end, actual_start, actual_end,
|
||||||
|
depends_on, blocked_by,
|
||||||
|
evidence_required, evidence_provided,
|
||||||
|
notes, risk_notes,
|
||||||
|
source_row, source_file, sort_order,
|
||||||
|
created_at, updated_at
|
||||||
|
) VALUES (
|
||||||
|
$1, $2, $3, $4, $5, $6, $7,
|
||||||
|
$8, $9, $10,
|
||||||
|
$11, $12, $13,
|
||||||
|
$14, $15, $16,
|
||||||
|
$17, $18, $19, $20,
|
||||||
|
$21, $22,
|
||||||
|
$23, $24,
|
||||||
|
$25, $26,
|
||||||
|
$27, $28, $29,
|
||||||
|
$30, $31
|
||||||
|
)
|
||||||
|
`,
|
||||||
|
item.ID, item.RoadmapID, item.Title, item.Description, string(item.Category), string(item.Priority), string(item.Status),
|
||||||
|
item.ControlID, item.RegulationRef, item.GapID,
|
||||||
|
item.EffortDays, item.EffortHours, item.EstimatedCost,
|
||||||
|
item.AssigneeID, item.AssigneeName, item.Department,
|
||||||
|
item.PlannedStart, item.PlannedEnd, item.ActualStart, item.ActualEnd,
|
||||||
|
dependsOn, blockedBy,
|
||||||
|
evidenceReq, evidenceProv,
|
||||||
|
item.Notes, item.RiskNotes,
|
||||||
|
item.SourceRow, item.SourceFile, item.SortOrder,
|
||||||
|
item.CreatedAt, item.UpdatedAt,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return tx.Commit(ctx)
|
||||||
|
}
|
||||||
227
ai-compliance-sdk/internal/roadmap/store_roadmap.go
Normal file
227
ai-compliance-sdk/internal/roadmap/store_roadmap.go
Normal file
@@ -0,0 +1,227 @@
|
|||||||
|
package roadmap
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Store handles roadmap data persistence
|
||||||
|
type Store struct {
|
||||||
|
pool *pgxpool.Pool
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewStore creates a new roadmap store
|
||||||
|
func NewStore(pool *pgxpool.Pool) *Store {
|
||||||
|
return &Store{pool: pool}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Roadmap CRUD Operations
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
// CreateRoadmap creates a new roadmap
|
||||||
|
func (s *Store) CreateRoadmap(ctx context.Context, r *Roadmap) error {
|
||||||
|
r.ID = uuid.New()
|
||||||
|
r.CreatedAt = time.Now().UTC()
|
||||||
|
r.UpdatedAt = r.CreatedAt
|
||||||
|
if r.Status == "" {
|
||||||
|
r.Status = "draft"
|
||||||
|
}
|
||||||
|
if r.Version == "" {
|
||||||
|
r.Version = "1.0"
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := s.pool.Exec(ctx, `
|
||||||
|
INSERT INTO roadmaps (
|
||||||
|
id, tenant_id, namespace_id, title, description, version,
|
||||||
|
assessment_id, portfolio_id, status,
|
||||||
|
total_items, completed_items, progress,
|
||||||
|
start_date, target_date,
|
||||||
|
created_at, updated_at, created_by
|
||||||
|
) VALUES (
|
||||||
|
$1, $2, $3, $4, $5, $6,
|
||||||
|
$7, $8, $9,
|
||||||
|
$10, $11, $12,
|
||||||
|
$13, $14,
|
||||||
|
$15, $16, $17
|
||||||
|
)
|
||||||
|
`,
|
||||||
|
r.ID, r.TenantID, r.NamespaceID, r.Title, r.Description, r.Version,
|
||||||
|
r.AssessmentID, r.PortfolioID, r.Status,
|
||||||
|
r.TotalItems, r.CompletedItems, r.Progress,
|
||||||
|
r.StartDate, r.TargetDate,
|
||||||
|
r.CreatedAt, r.UpdatedAt, r.CreatedBy,
|
||||||
|
)
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRoadmap retrieves a roadmap by ID
|
||||||
|
func (s *Store) GetRoadmap(ctx context.Context, id uuid.UUID) (*Roadmap, error) {
|
||||||
|
var r Roadmap
|
||||||
|
|
||||||
|
err := s.pool.QueryRow(ctx, `
|
||||||
|
SELECT
|
||||||
|
id, tenant_id, namespace_id, title, description, version,
|
||||||
|
assessment_id, portfolio_id, status,
|
||||||
|
total_items, completed_items, progress,
|
||||||
|
start_date, target_date,
|
||||||
|
created_at, updated_at, created_by
|
||||||
|
FROM roadmaps WHERE id = $1
|
||||||
|
`, id).Scan(
|
||||||
|
&r.ID, &r.TenantID, &r.NamespaceID, &r.Title, &r.Description, &r.Version,
|
||||||
|
&r.AssessmentID, &r.PortfolioID, &r.Status,
|
||||||
|
&r.TotalItems, &r.CompletedItems, &r.Progress,
|
||||||
|
&r.StartDate, &r.TargetDate,
|
||||||
|
&r.CreatedAt, &r.UpdatedAt, &r.CreatedBy,
|
||||||
|
)
|
||||||
|
|
||||||
|
if err == pgx.ErrNoRows {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &r, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListRoadmaps lists roadmaps for a tenant with optional filters
|
||||||
|
func (s *Store) ListRoadmaps(ctx context.Context, tenantID uuid.UUID, filters *RoadmapFilters) ([]Roadmap, error) {
|
||||||
|
query := `
|
||||||
|
SELECT
|
||||||
|
id, tenant_id, namespace_id, title, description, version,
|
||||||
|
assessment_id, portfolio_id, status,
|
||||||
|
total_items, completed_items, progress,
|
||||||
|
start_date, target_date,
|
||||||
|
created_at, updated_at, created_by
|
||||||
|
FROM roadmaps WHERE tenant_id = $1`
|
||||||
|
|
||||||
|
args := []interface{}{tenantID}
|
||||||
|
argIdx := 2
|
||||||
|
|
||||||
|
if filters != nil {
|
||||||
|
if filters.Status != "" {
|
||||||
|
query += fmt.Sprintf(" AND status = $%d", argIdx)
|
||||||
|
args = append(args, filters.Status)
|
||||||
|
argIdx++
|
||||||
|
}
|
||||||
|
if filters.AssessmentID != nil {
|
||||||
|
query += fmt.Sprintf(" AND assessment_id = $%d", argIdx)
|
||||||
|
args = append(args, *filters.AssessmentID)
|
||||||
|
argIdx++
|
||||||
|
}
|
||||||
|
if filters.PortfolioID != nil {
|
||||||
|
query += fmt.Sprintf(" AND portfolio_id = $%d", argIdx)
|
||||||
|
args = append(args, *filters.PortfolioID)
|
||||||
|
argIdx++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
query += " ORDER BY created_at DESC"
|
||||||
|
|
||||||
|
if filters != nil && filters.Limit > 0 {
|
||||||
|
query += fmt.Sprintf(" LIMIT $%d", argIdx)
|
||||||
|
args = append(args, filters.Limit)
|
||||||
|
argIdx++
|
||||||
|
|
||||||
|
if filters.Offset > 0 {
|
||||||
|
query += fmt.Sprintf(" OFFSET $%d", argIdx)
|
||||||
|
args = append(args, filters.Offset)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := s.pool.Query(ctx, query, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var roadmaps []Roadmap
|
||||||
|
for rows.Next() {
|
||||||
|
var r Roadmap
|
||||||
|
err := rows.Scan(
|
||||||
|
&r.ID, &r.TenantID, &r.NamespaceID, &r.Title, &r.Description, &r.Version,
|
||||||
|
&r.AssessmentID, &r.PortfolioID, &r.Status,
|
||||||
|
&r.TotalItems, &r.CompletedItems, &r.Progress,
|
||||||
|
&r.StartDate, &r.TargetDate,
|
||||||
|
&r.CreatedAt, &r.UpdatedAt, &r.CreatedBy,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
roadmaps = append(roadmaps, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
return roadmaps, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateRoadmap updates a roadmap
|
||||||
|
func (s *Store) UpdateRoadmap(ctx context.Context, r *Roadmap) error {
|
||||||
|
r.UpdatedAt = time.Now().UTC()
|
||||||
|
|
||||||
|
_, err := s.pool.Exec(ctx, `
|
||||||
|
UPDATE roadmaps SET
|
||||||
|
title = $2, description = $3, version = $4,
|
||||||
|
assessment_id = $5, portfolio_id = $6, status = $7,
|
||||||
|
total_items = $8, completed_items = $9, progress = $10,
|
||||||
|
start_date = $11, target_date = $12,
|
||||||
|
updated_at = $13
|
||||||
|
WHERE id = $1
|
||||||
|
`,
|
||||||
|
r.ID, r.Title, r.Description, r.Version,
|
||||||
|
r.AssessmentID, r.PortfolioID, r.Status,
|
||||||
|
r.TotalItems, r.CompletedItems, r.Progress,
|
||||||
|
r.StartDate, r.TargetDate,
|
||||||
|
r.UpdatedAt,
|
||||||
|
)
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteRoadmap deletes a roadmap and its items
|
||||||
|
func (s *Store) DeleteRoadmap(ctx context.Context, id uuid.UUID) error {
|
||||||
|
// Delete items first
|
||||||
|
_, err := s.pool.Exec(ctx, "DELETE FROM roadmap_items WHERE roadmap_id = $1", id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete roadmap
|
||||||
|
_, err = s.pool.Exec(ctx, "DELETE FROM roadmaps WHERE id = $1", id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateRoadmapProgress recalculates and updates roadmap progress
|
||||||
|
func (s *Store) UpdateRoadmapProgress(ctx context.Context, roadmapID uuid.UUID) error {
|
||||||
|
var total, completed int
|
||||||
|
|
||||||
|
s.pool.QueryRow(ctx,
|
||||||
|
"SELECT COUNT(*) FROM roadmap_items WHERE roadmap_id = $1",
|
||||||
|
roadmapID).Scan(&total)
|
||||||
|
|
||||||
|
s.pool.QueryRow(ctx,
|
||||||
|
"SELECT COUNT(*) FROM roadmap_items WHERE roadmap_id = $1 AND status = 'COMPLETED'",
|
||||||
|
roadmapID).Scan(&completed)
|
||||||
|
|
||||||
|
progress := 0
|
||||||
|
if total > 0 {
|
||||||
|
progress = (completed * 100) / total
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := s.pool.Exec(ctx, `
|
||||||
|
UPDATE roadmaps SET
|
||||||
|
total_items = $2,
|
||||||
|
completed_items = $3,
|
||||||
|
progress = $4,
|
||||||
|
updated_at = $5
|
||||||
|
WHERE id = $1
|
||||||
|
`, roadmapID, total, completed, progress, time.Now().UTC())
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
||||||
@@ -1,757 +0,0 @@
|
|||||||
package training
|
|
||||||
|
|
||||||
import (
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/google/uuid"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Constants / Enums
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
// RegulationArea represents a compliance regulation area
|
|
||||||
type RegulationArea string
|
|
||||||
|
|
||||||
const (
|
|
||||||
RegulationDSGVO RegulationArea = "dsgvo"
|
|
||||||
RegulationNIS2 RegulationArea = "nis2"
|
|
||||||
RegulationISO27001 RegulationArea = "iso27001"
|
|
||||||
RegulationAIAct RegulationArea = "ai_act"
|
|
||||||
RegulationGeschGehG RegulationArea = "geschgehg"
|
|
||||||
RegulationHinSchG RegulationArea = "hinschg"
|
|
||||||
)
|
|
||||||
|
|
||||||
// FrequencyType represents the training frequency
|
|
||||||
type FrequencyType string
|
|
||||||
|
|
||||||
const (
|
|
||||||
FrequencyOnboarding FrequencyType = "onboarding"
|
|
||||||
FrequencyAnnual FrequencyType = "annual"
|
|
||||||
FrequencyEventTrigger FrequencyType = "event_trigger"
|
|
||||||
FrequencyMicro FrequencyType = "micro"
|
|
||||||
)
|
|
||||||
|
|
||||||
// AssignmentStatus represents the status of a training assignment
|
|
||||||
type AssignmentStatus string
|
|
||||||
|
|
||||||
const (
|
|
||||||
AssignmentStatusPending AssignmentStatus = "pending"
|
|
||||||
AssignmentStatusInProgress AssignmentStatus = "in_progress"
|
|
||||||
AssignmentStatusCompleted AssignmentStatus = "completed"
|
|
||||||
AssignmentStatusOverdue AssignmentStatus = "overdue"
|
|
||||||
AssignmentStatusExpired AssignmentStatus = "expired"
|
|
||||||
)
|
|
||||||
|
|
||||||
// TriggerType represents how a training was assigned
|
|
||||||
type TriggerType string
|
|
||||||
|
|
||||||
const (
|
|
||||||
TriggerOnboarding TriggerType = "onboarding"
|
|
||||||
TriggerAnnual TriggerType = "annual"
|
|
||||||
TriggerEvent TriggerType = "event"
|
|
||||||
TriggerManual TriggerType = "manual"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ContentFormat represents the format of module content
|
|
||||||
type ContentFormat string
|
|
||||||
|
|
||||||
const (
|
|
||||||
ContentFormatMarkdown ContentFormat = "markdown"
|
|
||||||
ContentFormatHTML ContentFormat = "html"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Difficulty represents the difficulty level of a quiz question
|
|
||||||
type Difficulty string
|
|
||||||
|
|
||||||
const (
|
|
||||||
DifficultyEasy Difficulty = "easy"
|
|
||||||
DifficultyMedium Difficulty = "medium"
|
|
||||||
DifficultyHard Difficulty = "hard"
|
|
||||||
)
|
|
||||||
|
|
||||||
// AuditAction represents an action in the audit trail
|
|
||||||
type AuditAction string
|
|
||||||
|
|
||||||
const (
|
|
||||||
AuditActionAssigned AuditAction = "assigned"
|
|
||||||
AuditActionStarted AuditAction = "started"
|
|
||||||
AuditActionCompleted AuditAction = "completed"
|
|
||||||
AuditActionQuizSubmitted AuditAction = "quiz_submitted"
|
|
||||||
AuditActionEscalated AuditAction = "escalated"
|
|
||||||
AuditActionCertificateIssued AuditAction = "certificate_issued"
|
|
||||||
AuditActionContentGenerated AuditAction = "content_generated"
|
|
||||||
)
|
|
||||||
|
|
||||||
// AuditEntityType represents the type of entity in audit log
|
|
||||||
type AuditEntityType string
|
|
||||||
|
|
||||||
const (
|
|
||||||
AuditEntityAssignment AuditEntityType = "assignment"
|
|
||||||
AuditEntityModule AuditEntityType = "module"
|
|
||||||
AuditEntityQuiz AuditEntityType = "quiz"
|
|
||||||
AuditEntityCertificate AuditEntityType = "certificate"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Role Constants
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
const (
|
|
||||||
RoleR1 = "R1" // Geschaeftsfuehrung
|
|
||||||
RoleR2 = "R2" // IT-Leitung
|
|
||||||
RoleR3 = "R3" // DSB
|
|
||||||
RoleR4 = "R4" // ISB
|
|
||||||
RoleR5 = "R5" // HR
|
|
||||||
RoleR6 = "R6" // Einkauf
|
|
||||||
RoleR7 = "R7" // Fachabteilung
|
|
||||||
RoleR8 = "R8" // IT-Admin
|
|
||||||
RoleR9 = "R9" // Alle Mitarbeiter
|
|
||||||
RoleR10 = "R10" // Behoerden / Oeffentlicher Dienst
|
|
||||||
)
|
|
||||||
|
|
||||||
// RoleLabels maps role codes to human-readable labels
|
|
||||||
var RoleLabels = map[string]string{
|
|
||||||
RoleR1: "Geschaeftsfuehrung",
|
|
||||||
RoleR2: "IT-Leitung",
|
|
||||||
RoleR3: "Datenschutzbeauftragter",
|
|
||||||
RoleR4: "Informationssicherheitsbeauftragter",
|
|
||||||
RoleR5: "HR / Personal",
|
|
||||||
RoleR6: "Einkauf / Beschaffung",
|
|
||||||
RoleR7: "Fachabteilung",
|
|
||||||
RoleR8: "IT-Administration",
|
|
||||||
RoleR9: "Alle Mitarbeiter",
|
|
||||||
RoleR10: "Behoerden / Oeffentlicher Dienst",
|
|
||||||
}
|
|
||||||
|
|
||||||
// NIS2RoleMapping maps internal roles to NIS2 levels
|
|
||||||
var NIS2RoleMapping = map[string]string{
|
|
||||||
RoleR1: "N1", // Geschaeftsfuehrung
|
|
||||||
RoleR2: "N2", // IT-Leitung
|
|
||||||
RoleR3: "N3", // DSB
|
|
||||||
RoleR4: "N3", // ISB
|
|
||||||
RoleR5: "N4", // HR
|
|
||||||
RoleR6: "N4", // Einkauf
|
|
||||||
RoleR7: "N5", // Fachabteilung
|
|
||||||
RoleR8: "N2", // IT-Admin
|
|
||||||
RoleR9: "N5", // Alle Mitarbeiter
|
|
||||||
RoleR10: "N4", // Behoerden
|
|
||||||
}
|
|
||||||
|
|
||||||
// TargetAudienceRoleMapping maps canonical control target_audience values to CTM roles
|
|
||||||
var TargetAudienceRoleMapping = map[string][]string{
|
|
||||||
"enterprise": {RoleR1, RoleR4, RoleR5, RoleR6, RoleR7, RoleR9}, // Unternehmen
|
|
||||||
"authority": {RoleR10}, // Behoerden
|
|
||||||
"provider": {RoleR2, RoleR8}, // IT-Dienstleister
|
|
||||||
"all": {RoleR1, RoleR2, RoleR3, RoleR4, RoleR5, RoleR6, RoleR7, RoleR8, RoleR9, RoleR10},
|
|
||||||
}
|
|
||||||
|
|
||||||
// CategoryRoleMapping provides additional role hints based on control category
|
|
||||||
var CategoryRoleMapping = map[string][]string{
|
|
||||||
"encryption": {RoleR2, RoleR8},
|
|
||||||
"authentication": {RoleR2, RoleR8, RoleR9},
|
|
||||||
"network": {RoleR2, RoleR8},
|
|
||||||
"data_protection": {RoleR3, RoleR5, RoleR9},
|
|
||||||
"logging": {RoleR2, RoleR4, RoleR8},
|
|
||||||
"incident": {RoleR1, RoleR4},
|
|
||||||
"continuity": {RoleR1, RoleR2, RoleR4},
|
|
||||||
"compliance": {RoleR1, RoleR3, RoleR4},
|
|
||||||
"supply_chain": {RoleR6},
|
|
||||||
"physical": {RoleR7},
|
|
||||||
"personnel": {RoleR5, RoleR9},
|
|
||||||
"application": {RoleR8},
|
|
||||||
"system": {RoleR2, RoleR8},
|
|
||||||
"risk": {RoleR1, RoleR4},
|
|
||||||
"governance": {RoleR1, RoleR4},
|
|
||||||
"hardware": {RoleR2, RoleR8},
|
|
||||||
"identity": {RoleR2, RoleR3, RoleR8},
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Main Entities
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
// TrainingModule represents a compliance training module
|
|
||||||
type TrainingModule struct {
|
|
||||||
ID uuid.UUID `json:"id"`
|
|
||||||
TenantID uuid.UUID `json:"tenant_id"`
|
|
||||||
AcademyCourseID *uuid.UUID `json:"academy_course_id,omitempty"`
|
|
||||||
ModuleCode string `json:"module_code"`
|
|
||||||
Title string `json:"title"`
|
|
||||||
Description string `json:"description,omitempty"`
|
|
||||||
RegulationArea RegulationArea `json:"regulation_area"`
|
|
||||||
NIS2Relevant bool `json:"nis2_relevant"`
|
|
||||||
ISOControls []string `json:"iso_controls"` // JSONB
|
|
||||||
FrequencyType FrequencyType `json:"frequency_type"`
|
|
||||||
ValidityDays int `json:"validity_days"`
|
|
||||||
RiskWeight float64 `json:"risk_weight"`
|
|
||||||
ContentType string `json:"content_type"`
|
|
||||||
DurationMinutes int `json:"duration_minutes"`
|
|
||||||
PassThreshold int `json:"pass_threshold"`
|
|
||||||
IsActive bool `json:"is_active"`
|
|
||||||
SortOrder int `json:"sort_order"`
|
|
||||||
CreatedAt time.Time `json:"created_at"`
|
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// TrainingMatrixEntry represents a role-to-module mapping in the CTM
|
|
||||||
type TrainingMatrixEntry struct {
|
|
||||||
ID uuid.UUID `json:"id"`
|
|
||||||
TenantID uuid.UUID `json:"tenant_id"`
|
|
||||||
RoleCode string `json:"role_code"`
|
|
||||||
ModuleID uuid.UUID `json:"module_id"`
|
|
||||||
IsMandatory bool `json:"is_mandatory"`
|
|
||||||
Priority int `json:"priority"`
|
|
||||||
CreatedAt time.Time `json:"created_at"`
|
|
||||||
// Joined fields (optional, populated in queries)
|
|
||||||
ModuleCode string `json:"module_code,omitempty"`
|
|
||||||
ModuleTitle string `json:"module_title,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// TrainingAssignment represents a user's training assignment
|
|
||||||
type TrainingAssignment struct {
|
|
||||||
ID uuid.UUID `json:"id"`
|
|
||||||
TenantID uuid.UUID `json:"tenant_id"`
|
|
||||||
ModuleID uuid.UUID `json:"module_id"`
|
|
||||||
UserID uuid.UUID `json:"user_id"`
|
|
||||||
UserName string `json:"user_name"`
|
|
||||||
UserEmail string `json:"user_email"`
|
|
||||||
RoleCode string `json:"role_code,omitempty"`
|
|
||||||
TriggerType TriggerType `json:"trigger_type"`
|
|
||||||
TriggerEvent string `json:"trigger_event,omitempty"`
|
|
||||||
Status AssignmentStatus `json:"status"`
|
|
||||||
ProgressPercent int `json:"progress_percent"`
|
|
||||||
QuizScore *float64 `json:"quiz_score,omitempty"`
|
|
||||||
QuizPassed *bool `json:"quiz_passed,omitempty"`
|
|
||||||
QuizAttempts int `json:"quiz_attempts"`
|
|
||||||
StartedAt *time.Time `json:"started_at,omitempty"`
|
|
||||||
CompletedAt *time.Time `json:"completed_at,omitempty"`
|
|
||||||
Deadline time.Time `json:"deadline"`
|
|
||||||
CertificateID *uuid.UUID `json:"certificate_id,omitempty"`
|
|
||||||
EscalationLevel int `json:"escalation_level"`
|
|
||||||
LastEscalationAt *time.Time `json:"last_escalation_at,omitempty"`
|
|
||||||
EnrollmentID *uuid.UUID `json:"enrollment_id,omitempty"`
|
|
||||||
CreatedAt time.Time `json:"created_at"`
|
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
|
||||||
// Joined fields
|
|
||||||
ModuleCode string `json:"module_code,omitempty"`
|
|
||||||
ModuleTitle string `json:"module_title,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// QuizQuestion represents a persistent quiz question for a module
|
|
||||||
type QuizQuestion struct {
|
|
||||||
ID uuid.UUID `json:"id"`
|
|
||||||
ModuleID uuid.UUID `json:"module_id"`
|
|
||||||
Question string `json:"question"`
|
|
||||||
Options []string `json:"options"` // JSONB
|
|
||||||
CorrectIndex int `json:"correct_index"`
|
|
||||||
Explanation string `json:"explanation,omitempty"`
|
|
||||||
Difficulty Difficulty `json:"difficulty"`
|
|
||||||
IsActive bool `json:"is_active"`
|
|
||||||
SortOrder int `json:"sort_order"`
|
|
||||||
CreatedAt time.Time `json:"created_at"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// QuizAttempt represents a single quiz attempt by a user
|
|
||||||
type QuizAttempt struct {
|
|
||||||
ID uuid.UUID `json:"id"`
|
|
||||||
AssignmentID uuid.UUID `json:"assignment_id"`
|
|
||||||
UserID uuid.UUID `json:"user_id"`
|
|
||||||
Answers []QuizAnswer `json:"answers"` // JSONB
|
|
||||||
Score float64 `json:"score"`
|
|
||||||
Passed bool `json:"passed"`
|
|
||||||
CorrectCount int `json:"correct_count"`
|
|
||||||
TotalCount int `json:"total_count"`
|
|
||||||
DurationSeconds *int `json:"duration_seconds,omitempty"`
|
|
||||||
AttemptedAt time.Time `json:"attempted_at"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// QuizAnswer represents a single answer within a quiz attempt
|
|
||||||
type QuizAnswer struct {
|
|
||||||
QuestionID uuid.UUID `json:"question_id"`
|
|
||||||
SelectedIndex int `json:"selected_index"`
|
|
||||||
Correct bool `json:"correct"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// AuditLogEntry represents an entry in the training audit trail
|
|
||||||
type AuditLogEntry struct {
|
|
||||||
ID uuid.UUID `json:"id"`
|
|
||||||
TenantID uuid.UUID `json:"tenant_id"`
|
|
||||||
UserID *uuid.UUID `json:"user_id,omitempty"`
|
|
||||||
Action AuditAction `json:"action"`
|
|
||||||
EntityType AuditEntityType `json:"entity_type"`
|
|
||||||
EntityID *uuid.UUID `json:"entity_id,omitempty"`
|
|
||||||
Details map[string]interface{} `json:"details"` // JSONB
|
|
||||||
IPAddress string `json:"ip_address,omitempty"`
|
|
||||||
CreatedAt time.Time `json:"created_at"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// ModuleContent represents LLM-generated or manual content for a module
|
|
||||||
type ModuleContent struct {
|
|
||||||
ID uuid.UUID `json:"id"`
|
|
||||||
ModuleID uuid.UUID `json:"module_id"`
|
|
||||||
Version int `json:"version"`
|
|
||||||
ContentFormat ContentFormat `json:"content_format"`
|
|
||||||
ContentBody string `json:"content_body"`
|
|
||||||
Summary string `json:"summary,omitempty"`
|
|
||||||
GeneratedBy string `json:"generated_by,omitempty"`
|
|
||||||
LLMModel string `json:"llm_model,omitempty"`
|
|
||||||
IsPublished bool `json:"is_published"`
|
|
||||||
ReviewedBy *uuid.UUID `json:"reviewed_by,omitempty"`
|
|
||||||
ReviewedAt *time.Time `json:"reviewed_at,omitempty"`
|
|
||||||
CreatedAt time.Time `json:"created_at"`
|
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// TrainingStats contains aggregated training metrics
|
|
||||||
type TrainingStats struct {
|
|
||||||
TotalModules int `json:"total_modules"`
|
|
||||||
TotalAssignments int `json:"total_assignments"`
|
|
||||||
CompletionRate float64 `json:"completion_rate"`
|
|
||||||
OverdueCount int `json:"overdue_count"`
|
|
||||||
PendingCount int `json:"pending_count"`
|
|
||||||
InProgressCount int `json:"in_progress_count"`
|
|
||||||
CompletedCount int `json:"completed_count"`
|
|
||||||
AvgQuizScore float64 `json:"avg_quiz_score"`
|
|
||||||
AvgCompletionDays float64 `json:"avg_completion_days"`
|
|
||||||
UpcomingDeadlines int `json:"upcoming_deadlines"` // within 7 days
|
|
||||||
}
|
|
||||||
|
|
||||||
// ComplianceGap represents a missing or overdue training requirement
|
|
||||||
type ComplianceGap struct {
|
|
||||||
ModuleID uuid.UUID `json:"module_id"`
|
|
||||||
ModuleCode string `json:"module_code"`
|
|
||||||
ModuleTitle string `json:"module_title"`
|
|
||||||
RegulationArea RegulationArea `json:"regulation_area"`
|
|
||||||
RoleCode string `json:"role_code"`
|
|
||||||
IsMandatory bool `json:"is_mandatory"`
|
|
||||||
AssignmentID *uuid.UUID `json:"assignment_id,omitempty"`
|
|
||||||
Status string `json:"status"` // "missing", "overdue", "expired"
|
|
||||||
Deadline *time.Time `json:"deadline,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// EscalationResult represents the result of an escalation check
|
|
||||||
type EscalationResult struct {
|
|
||||||
AssignmentID uuid.UUID `json:"assignment_id"`
|
|
||||||
UserID uuid.UUID `json:"user_id"`
|
|
||||||
UserName string `json:"user_name"`
|
|
||||||
UserEmail string `json:"user_email"`
|
|
||||||
ModuleTitle string `json:"module_title"`
|
|
||||||
PreviousLevel int `json:"previous_level"`
|
|
||||||
NewLevel int `json:"new_level"`
|
|
||||||
DaysOverdue int `json:"days_overdue"`
|
|
||||||
EscalationLabel string `json:"escalation_label"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// DeadlineInfo represents upcoming deadline information
|
|
||||||
type DeadlineInfo struct {
|
|
||||||
AssignmentID uuid.UUID `json:"assignment_id"`
|
|
||||||
ModuleCode string `json:"module_code"`
|
|
||||||
ModuleTitle string `json:"module_title"`
|
|
||||||
UserID uuid.UUID `json:"user_id"`
|
|
||||||
UserName string `json:"user_name"`
|
|
||||||
Deadline time.Time `json:"deadline"`
|
|
||||||
DaysLeft int `json:"days_left"`
|
|
||||||
Status AssignmentStatus `json:"status"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Filter Types
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
// ModuleFilters defines filters for listing modules
|
|
||||||
type ModuleFilters struct {
|
|
||||||
RegulationArea RegulationArea
|
|
||||||
FrequencyType FrequencyType
|
|
||||||
IsActive *bool
|
|
||||||
NIS2Relevant *bool
|
|
||||||
Search string
|
|
||||||
Limit int
|
|
||||||
Offset int
|
|
||||||
}
|
|
||||||
|
|
||||||
// AssignmentFilters defines filters for listing assignments
|
|
||||||
type AssignmentFilters struct {
|
|
||||||
ModuleID *uuid.UUID
|
|
||||||
UserID *uuid.UUID
|
|
||||||
RoleCode string
|
|
||||||
Status AssignmentStatus
|
|
||||||
Overdue *bool
|
|
||||||
Limit int
|
|
||||||
Offset int
|
|
||||||
}
|
|
||||||
|
|
||||||
// AuditLogFilters defines filters for listing audit log entries
|
|
||||||
type AuditLogFilters struct {
|
|
||||||
UserID *uuid.UUID
|
|
||||||
Action AuditAction
|
|
||||||
EntityType AuditEntityType
|
|
||||||
Limit int
|
|
||||||
Offset int
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// API Request/Response Types
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
// CreateModuleRequest is the API request for creating a training module
|
|
||||||
type CreateModuleRequest struct {
|
|
||||||
ModuleCode string `json:"module_code" binding:"required"`
|
|
||||||
Title string `json:"title" binding:"required"`
|
|
||||||
Description string `json:"description,omitempty"`
|
|
||||||
RegulationArea RegulationArea `json:"regulation_area" binding:"required"`
|
|
||||||
NIS2Relevant bool `json:"nis2_relevant"`
|
|
||||||
ISOControls []string `json:"iso_controls,omitempty"`
|
|
||||||
FrequencyType FrequencyType `json:"frequency_type" binding:"required"`
|
|
||||||
ValidityDays int `json:"validity_days"`
|
|
||||||
RiskWeight float64 `json:"risk_weight"`
|
|
||||||
ContentType string `json:"content_type"`
|
|
||||||
DurationMinutes int `json:"duration_minutes"`
|
|
||||||
PassThreshold int `json:"pass_threshold"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// UpdateModuleRequest is the API request for updating a training module
|
|
||||||
type UpdateModuleRequest struct {
|
|
||||||
Title *string `json:"title,omitempty"`
|
|
||||||
Description *string `json:"description,omitempty"`
|
|
||||||
NIS2Relevant *bool `json:"nis2_relevant,omitempty"`
|
|
||||||
ISOControls []string `json:"iso_controls,omitempty"`
|
|
||||||
ValidityDays *int `json:"validity_days,omitempty"`
|
|
||||||
RiskWeight *float64 `json:"risk_weight,omitempty"`
|
|
||||||
DurationMinutes *int `json:"duration_minutes,omitempty"`
|
|
||||||
PassThreshold *int `json:"pass_threshold,omitempty"`
|
|
||||||
IsActive *bool `json:"is_active,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetMatrixEntryRequest is the API request for setting a CTM entry
|
|
||||||
type SetMatrixEntryRequest struct {
|
|
||||||
RoleCode string `json:"role_code" binding:"required"`
|
|
||||||
ModuleID uuid.UUID `json:"module_id" binding:"required"`
|
|
||||||
IsMandatory bool `json:"is_mandatory"`
|
|
||||||
Priority int `json:"priority"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// ComputeAssignmentsRequest is the API request for computing assignments
|
|
||||||
type ComputeAssignmentsRequest struct {
|
|
||||||
UserID uuid.UUID `json:"user_id" binding:"required"`
|
|
||||||
UserName string `json:"user_name" binding:"required"`
|
|
||||||
UserEmail string `json:"user_email" binding:"required"`
|
|
||||||
Roles []string `json:"roles" binding:"required"`
|
|
||||||
Trigger string `json:"trigger"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// UpdateAssignmentProgressRequest updates progress on an assignment
|
|
||||||
type UpdateAssignmentProgressRequest struct {
|
|
||||||
Progress int `json:"progress" binding:"required"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// SubmitTrainingQuizRequest is the API request for submitting a quiz
|
|
||||||
type SubmitTrainingQuizRequest struct {
|
|
||||||
AssignmentID uuid.UUID `json:"assignment_id" binding:"required"`
|
|
||||||
Answers []QuizAnswer `json:"answers" binding:"required"`
|
|
||||||
DurationSeconds *int `json:"duration_seconds,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// SubmitTrainingQuizResponse is the API response for quiz submission
|
|
||||||
type SubmitTrainingQuizResponse struct {
|
|
||||||
AttemptID uuid.UUID `json:"attempt_id"`
|
|
||||||
Score float64 `json:"score"`
|
|
||||||
Passed bool `json:"passed"`
|
|
||||||
CorrectCount int `json:"correct_count"`
|
|
||||||
TotalCount int `json:"total_count"`
|
|
||||||
Threshold int `json:"threshold"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// GenerateContentRequest is the API request for LLM content generation
|
|
||||||
type GenerateContentRequest struct {
|
|
||||||
ModuleID uuid.UUID `json:"module_id" binding:"required"`
|
|
||||||
Language string `json:"language"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// GenerateQuizRequest is the API request for LLM quiz generation
|
|
||||||
type GenerateQuizRequest struct {
|
|
||||||
ModuleID uuid.UUID `json:"module_id" binding:"required"`
|
|
||||||
Count int `json:"count"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// PublishContentRequest is the API request for publishing content
|
|
||||||
type PublishContentRequest struct {
|
|
||||||
ReviewedBy uuid.UUID `json:"reviewed_by"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// BulkAssignRequest is the API request for bulk assigning a module
|
|
||||||
type BulkAssignRequest struct {
|
|
||||||
ModuleID uuid.UUID `json:"module_id" binding:"required"`
|
|
||||||
RoleCodes []string `json:"role_codes" binding:"required"`
|
|
||||||
Trigger string `json:"trigger"`
|
|
||||||
Deadline time.Time `json:"deadline" binding:"required"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// ModuleListResponse is the API response for listing modules
|
|
||||||
type ModuleListResponse struct {
|
|
||||||
Modules []TrainingModule `json:"modules"`
|
|
||||||
Total int `json:"total"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// AssignmentListResponse is the API response for listing assignments
|
|
||||||
type AssignmentListResponse struct {
|
|
||||||
Assignments []TrainingAssignment `json:"assignments"`
|
|
||||||
Total int `json:"total"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// MatrixResponse is the API response for the full training matrix
|
|
||||||
type MatrixResponse struct {
|
|
||||||
Entries map[string][]TrainingMatrixEntry `json:"entries"` // role_code -> entries
|
|
||||||
Roles map[string]string `json:"roles"` // role_code -> label
|
|
||||||
}
|
|
||||||
|
|
||||||
// AuditLogResponse is the API response for listing audit log entries
|
|
||||||
type AuditLogResponse struct {
|
|
||||||
Entries []AuditLogEntry `json:"entries"`
|
|
||||||
Total int `json:"total"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// EscalationResponse is the API response for escalation check
|
|
||||||
type EscalationResponse struct {
|
|
||||||
Results []EscalationResult `json:"results"`
|
|
||||||
TotalChecked int `json:"total_checked"`
|
|
||||||
Escalated int `json:"escalated"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// DeadlineListResponse is the API response for listing deadlines
|
|
||||||
type DeadlineListResponse struct {
|
|
||||||
Deadlines []DeadlineInfo `json:"deadlines"`
|
|
||||||
Total int `json:"total"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// BulkResult holds the result of a bulk generation operation
|
|
||||||
type BulkResult struct {
|
|
||||||
Generated int `json:"generated"`
|
|
||||||
Skipped int `json:"skipped"`
|
|
||||||
Errors []string `json:"errors"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Training Block Types (Controls → Schulungsmodule Pipeline)
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
// TrainingBlockConfig defines how canonical controls are grouped into training modules
|
|
||||||
type TrainingBlockConfig struct {
|
|
||||||
ID uuid.UUID `json:"id"`
|
|
||||||
TenantID uuid.UUID `json:"tenant_id"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
Description string `json:"description,omitempty"`
|
|
||||||
DomainFilter string `json:"domain_filter,omitempty"` // "AUTH", "CRYP", etc.
|
|
||||||
CategoryFilter string `json:"category_filter,omitempty"` // "authentication", etc.
|
|
||||||
SeverityFilter string `json:"severity_filter,omitempty"` // "high", "critical"
|
|
||||||
TargetAudienceFilter string `json:"target_audience_filter,omitempty"` // "enterprise", "authority", "provider", "all"
|
|
||||||
RegulationArea RegulationArea `json:"regulation_area"`
|
|
||||||
ModuleCodePrefix string `json:"module_code_prefix"`
|
|
||||||
FrequencyType FrequencyType `json:"frequency_type"`
|
|
||||||
DurationMinutes int `json:"duration_minutes"`
|
|
||||||
PassThreshold int `json:"pass_threshold"`
|
|
||||||
MaxControlsPerModule int `json:"max_controls_per_module"`
|
|
||||||
IsActive bool `json:"is_active"`
|
|
||||||
LastGeneratedAt *time.Time `json:"last_generated_at,omitempty"`
|
|
||||||
CreatedAt time.Time `json:"created_at"`
|
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// TrainingBlockControlLink tracks which canonical controls are linked to which module
|
|
||||||
type TrainingBlockControlLink struct {
|
|
||||||
ID uuid.UUID `json:"id"`
|
|
||||||
BlockConfigID uuid.UUID `json:"block_config_id"`
|
|
||||||
ModuleID uuid.UUID `json:"module_id"`
|
|
||||||
ControlID string `json:"control_id"`
|
|
||||||
ControlTitle string `json:"control_title"`
|
|
||||||
ControlObjective string `json:"control_objective"`
|
|
||||||
ControlRequirements []string `json:"control_requirements"`
|
|
||||||
SortOrder int `json:"sort_order"`
|
|
||||||
CreatedAt time.Time `json:"created_at"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// CanonicalControlSummary is a lightweight view on canonical_controls for the training pipeline
|
|
||||||
type CanonicalControlSummary struct {
|
|
||||||
ControlID string `json:"control_id"`
|
|
||||||
Title string `json:"title"`
|
|
||||||
Objective string `json:"objective"`
|
|
||||||
Rationale string `json:"rationale"`
|
|
||||||
Requirements []string `json:"requirements"`
|
|
||||||
Severity string `json:"severity"`
|
|
||||||
Category string `json:"category"`
|
|
||||||
TargetAudience string `json:"target_audience"`
|
|
||||||
Tags []string `json:"tags"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// CanonicalControlMeta provides aggregated metadata about canonical controls
|
|
||||||
type CanonicalControlMeta struct {
|
|
||||||
Domains []DomainCount `json:"domains"`
|
|
||||||
Categories []CategoryCount `json:"categories"`
|
|
||||||
Audiences []AudienceCount `json:"audiences"`
|
|
||||||
Total int `json:"total"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// DomainCount is a domain with its control count
|
|
||||||
type DomainCount struct {
|
|
||||||
Domain string `json:"domain"`
|
|
||||||
Count int `json:"count"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// CategoryCount is a category with its control count
|
|
||||||
type CategoryCount struct {
|
|
||||||
Category string `json:"category"`
|
|
||||||
Count int `json:"count"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// AudienceCount is a target audience with its control count
|
|
||||||
type AudienceCount struct {
|
|
||||||
Audience string `json:"audience"`
|
|
||||||
Count int `json:"count"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// CreateBlockConfigRequest is the API request for creating a block config
|
|
||||||
type CreateBlockConfigRequest struct {
|
|
||||||
Name string `json:"name" binding:"required"`
|
|
||||||
Description string `json:"description,omitempty"`
|
|
||||||
DomainFilter string `json:"domain_filter,omitempty"`
|
|
||||||
CategoryFilter string `json:"category_filter,omitempty"`
|
|
||||||
SeverityFilter string `json:"severity_filter,omitempty"`
|
|
||||||
TargetAudienceFilter string `json:"target_audience_filter,omitempty"`
|
|
||||||
RegulationArea RegulationArea `json:"regulation_area" binding:"required"`
|
|
||||||
ModuleCodePrefix string `json:"module_code_prefix" binding:"required"`
|
|
||||||
FrequencyType FrequencyType `json:"frequency_type"`
|
|
||||||
DurationMinutes int `json:"duration_minutes"`
|
|
||||||
PassThreshold int `json:"pass_threshold"`
|
|
||||||
MaxControlsPerModule int `json:"max_controls_per_module"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// UpdateBlockConfigRequest is the API request for updating a block config
|
|
||||||
type UpdateBlockConfigRequest struct {
|
|
||||||
Name *string `json:"name,omitempty"`
|
|
||||||
Description *string `json:"description,omitempty"`
|
|
||||||
DomainFilter *string `json:"domain_filter,omitempty"`
|
|
||||||
CategoryFilter *string `json:"category_filter,omitempty"`
|
|
||||||
SeverityFilter *string `json:"severity_filter,omitempty"`
|
|
||||||
TargetAudienceFilter *string `json:"target_audience_filter,omitempty"`
|
|
||||||
MaxControlsPerModule *int `json:"max_controls_per_module,omitempty"`
|
|
||||||
DurationMinutes *int `json:"duration_minutes,omitempty"`
|
|
||||||
PassThreshold *int `json:"pass_threshold,omitempty"`
|
|
||||||
IsActive *bool `json:"is_active,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Interactive Video / Checkpoint Types
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
// NarratorScript is an extended VideoScript with narrator persona and checkpoints
|
|
||||||
type NarratorScript struct {
|
|
||||||
Title string `json:"title"`
|
|
||||||
Intro string `json:"intro"`
|
|
||||||
Sections []NarratorSection `json:"sections"`
|
|
||||||
Outro string `json:"outro"`
|
|
||||||
TotalDurationEstimate int `json:"total_duration_estimate"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// NarratorSection is one narrative section with optional checkpoint
|
|
||||||
type NarratorSection struct {
|
|
||||||
Heading string `json:"heading"`
|
|
||||||
NarratorText string `json:"narrator_text"`
|
|
||||||
BulletPoints []string `json:"bullet_points"`
|
|
||||||
Transition string `json:"transition"`
|
|
||||||
Checkpoint *CheckpointDefinition `json:"checkpoint,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// CheckpointDefinition defines a quiz checkpoint within a video
|
|
||||||
type CheckpointDefinition struct {
|
|
||||||
Title string `json:"title"`
|
|
||||||
Questions []CheckpointQuestion `json:"questions"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// CheckpointQuestion is a quiz question within a checkpoint
|
|
||||||
type CheckpointQuestion struct {
|
|
||||||
Question string `json:"question"`
|
|
||||||
Options []string `json:"options"`
|
|
||||||
CorrectIndex int `json:"correct_index"`
|
|
||||||
Explanation string `json:"explanation"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Checkpoint is a DB record for a video checkpoint
|
|
||||||
type Checkpoint struct {
|
|
||||||
ID uuid.UUID `json:"id"`
|
|
||||||
ModuleID uuid.UUID `json:"module_id"`
|
|
||||||
CheckpointIndex int `json:"checkpoint_index"`
|
|
||||||
Title string `json:"title"`
|
|
||||||
TimestampSeconds float64 `json:"timestamp_seconds"`
|
|
||||||
CreatedAt time.Time `json:"created_at"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// CheckpointProgress tracks a user's progress on a checkpoint
|
|
||||||
type CheckpointProgress struct {
|
|
||||||
ID uuid.UUID `json:"id"`
|
|
||||||
AssignmentID uuid.UUID `json:"assignment_id"`
|
|
||||||
CheckpointID uuid.UUID `json:"checkpoint_id"`
|
|
||||||
Passed bool `json:"passed"`
|
|
||||||
Attempts int `json:"attempts"`
|
|
||||||
LastAttemptAt *time.Time `json:"last_attempt_at,omitempty"`
|
|
||||||
CreatedAt time.Time `json:"created_at"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// InteractiveVideoManifest is returned to the frontend player
|
|
||||||
type InteractiveVideoManifest struct {
|
|
||||||
MediaID uuid.UUID `json:"media_id"`
|
|
||||||
StreamURL string `json:"stream_url"`
|
|
||||||
Checkpoints []CheckpointManifestEntry `json:"checkpoints"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// CheckpointManifestEntry is one checkpoint in the manifest
|
|
||||||
type CheckpointManifestEntry struct {
|
|
||||||
CheckpointID uuid.UUID `json:"checkpoint_id"`
|
|
||||||
Index int `json:"index"`
|
|
||||||
Title string `json:"title"`
|
|
||||||
TimestampSeconds float64 `json:"timestamp_seconds"`
|
|
||||||
Questions []CheckpointQuestion `json:"questions"`
|
|
||||||
Progress *CheckpointProgress `json:"progress,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// SubmitCheckpointQuizRequest is the API request for submitting a checkpoint quiz
|
|
||||||
type SubmitCheckpointQuizRequest struct {
|
|
||||||
AssignmentID string `json:"assignment_id"`
|
|
||||||
Answers []int `json:"answers"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// SubmitCheckpointQuizResponse is the API response for a checkpoint quiz submission
|
|
||||||
type SubmitCheckpointQuizResponse struct {
|
|
||||||
Passed bool `json:"passed"`
|
|
||||||
Score float64 `json:"score"`
|
|
||||||
Feedback []CheckpointQuizFeedback `json:"feedback"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// CheckpointQuizFeedback is feedback for a single question
|
|
||||||
type CheckpointQuizFeedback struct {
|
|
||||||
Question string `json:"question"`
|
|
||||||
Correct bool `json:"correct"`
|
|
||||||
Explanation string `json:"explanation"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// GenerateBlockRequest is the API request for generating modules from a block config
|
|
||||||
type GenerateBlockRequest struct {
|
|
||||||
Language string `json:"language"`
|
|
||||||
AutoMatrix bool `json:"auto_matrix"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// PreviewBlockResponse shows what would be generated without writing to DB
|
|
||||||
type PreviewBlockResponse struct {
|
|
||||||
ControlCount int `json:"control_count"`
|
|
||||||
ModuleCount int `json:"module_count"`
|
|
||||||
Controls []CanonicalControlSummary `json:"controls"`
|
|
||||||
ProposedRoles []string `json:"proposed_roles"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// GenerateBlockResponse shows the result of a block generation
|
|
||||||
type GenerateBlockResponse struct {
|
|
||||||
ModulesCreated int `json:"modules_created"`
|
|
||||||
ControlsLinked int `json:"controls_linked"`
|
|
||||||
MatrixEntriesCreated int `json:"matrix_entries_created"`
|
|
||||||
ContentGenerated int `json:"content_generated"`
|
|
||||||
Errors []string `json:"errors,omitempty"`
|
|
||||||
}
|
|
||||||
141
ai-compliance-sdk/internal/training/models_api.go
Normal file
141
ai-compliance-sdk/internal/training/models_api.go
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
package training
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// API Request/Response Types
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
// CreateModuleRequest is the API request for creating a training module
|
||||||
|
type CreateModuleRequest struct {
|
||||||
|
ModuleCode string `json:"module_code" binding:"required"`
|
||||||
|
Title string `json:"title" binding:"required"`
|
||||||
|
Description string `json:"description,omitempty"`
|
||||||
|
RegulationArea RegulationArea `json:"regulation_area" binding:"required"`
|
||||||
|
NIS2Relevant bool `json:"nis2_relevant"`
|
||||||
|
ISOControls []string `json:"iso_controls,omitempty"`
|
||||||
|
FrequencyType FrequencyType `json:"frequency_type" binding:"required"`
|
||||||
|
ValidityDays int `json:"validity_days"`
|
||||||
|
RiskWeight float64 `json:"risk_weight"`
|
||||||
|
ContentType string `json:"content_type"`
|
||||||
|
DurationMinutes int `json:"duration_minutes"`
|
||||||
|
PassThreshold int `json:"pass_threshold"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateModuleRequest is the API request for updating a training module
|
||||||
|
type UpdateModuleRequest struct {
|
||||||
|
Title *string `json:"title,omitempty"`
|
||||||
|
Description *string `json:"description,omitempty"`
|
||||||
|
NIS2Relevant *bool `json:"nis2_relevant,omitempty"`
|
||||||
|
ISOControls []string `json:"iso_controls,omitempty"`
|
||||||
|
ValidityDays *int `json:"validity_days,omitempty"`
|
||||||
|
RiskWeight *float64 `json:"risk_weight,omitempty"`
|
||||||
|
DurationMinutes *int `json:"duration_minutes,omitempty"`
|
||||||
|
PassThreshold *int `json:"pass_threshold,omitempty"`
|
||||||
|
IsActive *bool `json:"is_active,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetMatrixEntryRequest is the API request for setting a CTM entry
|
||||||
|
type SetMatrixEntryRequest struct {
|
||||||
|
RoleCode string `json:"role_code" binding:"required"`
|
||||||
|
ModuleID uuid.UUID `json:"module_id" binding:"required"`
|
||||||
|
IsMandatory bool `json:"is_mandatory"`
|
||||||
|
Priority int `json:"priority"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ComputeAssignmentsRequest is the API request for computing assignments
|
||||||
|
type ComputeAssignmentsRequest struct {
|
||||||
|
UserID uuid.UUID `json:"user_id" binding:"required"`
|
||||||
|
UserName string `json:"user_name" binding:"required"`
|
||||||
|
UserEmail string `json:"user_email" binding:"required"`
|
||||||
|
Roles []string `json:"roles" binding:"required"`
|
||||||
|
Trigger string `json:"trigger"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateAssignmentProgressRequest updates progress on an assignment
|
||||||
|
type UpdateAssignmentProgressRequest struct {
|
||||||
|
Progress int `json:"progress" binding:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SubmitTrainingQuizRequest is the API request for submitting a quiz
|
||||||
|
type SubmitTrainingQuizRequest struct {
|
||||||
|
AssignmentID uuid.UUID `json:"assignment_id" binding:"required"`
|
||||||
|
Answers []QuizAnswer `json:"answers" binding:"required"`
|
||||||
|
DurationSeconds *int `json:"duration_seconds,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SubmitTrainingQuizResponse is the API response for quiz submission
|
||||||
|
type SubmitTrainingQuizResponse struct {
|
||||||
|
AttemptID uuid.UUID `json:"attempt_id"`
|
||||||
|
Score float64 `json:"score"`
|
||||||
|
Passed bool `json:"passed"`
|
||||||
|
CorrectCount int `json:"correct_count"`
|
||||||
|
TotalCount int `json:"total_count"`
|
||||||
|
Threshold int `json:"threshold"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateContentRequest is the API request for LLM content generation
|
||||||
|
type GenerateContentRequest struct {
|
||||||
|
ModuleID uuid.UUID `json:"module_id" binding:"required"`
|
||||||
|
Language string `json:"language"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateQuizRequest is the API request for LLM quiz generation
|
||||||
|
type GenerateQuizRequest struct {
|
||||||
|
ModuleID uuid.UUID `json:"module_id" binding:"required"`
|
||||||
|
Count int `json:"count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PublishContentRequest is the API request for publishing content
|
||||||
|
type PublishContentRequest struct {
|
||||||
|
ReviewedBy uuid.UUID `json:"reviewed_by"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// BulkAssignRequest is the API request for bulk assigning a module
|
||||||
|
type BulkAssignRequest struct {
|
||||||
|
ModuleID uuid.UUID `json:"module_id" binding:"required"`
|
||||||
|
RoleCodes []string `json:"role_codes" binding:"required"`
|
||||||
|
Trigger string `json:"trigger"`
|
||||||
|
Deadline time.Time `json:"deadline" binding:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ModuleListResponse is the API response for listing modules
|
||||||
|
type ModuleListResponse struct {
|
||||||
|
Modules []TrainingModule `json:"modules"`
|
||||||
|
Total int `json:"total"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AssignmentListResponse is the API response for listing assignments
|
||||||
|
type AssignmentListResponse struct {
|
||||||
|
Assignments []TrainingAssignment `json:"assignments"`
|
||||||
|
Total int `json:"total"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// MatrixResponse is the API response for the full training matrix
|
||||||
|
type MatrixResponse struct {
|
||||||
|
Entries map[string][]TrainingMatrixEntry `json:"entries"` // role_code -> entries
|
||||||
|
Roles map[string]string `json:"roles"` // role_code -> label
|
||||||
|
}
|
||||||
|
|
||||||
|
// AuditLogResponse is the API response for listing audit log entries
|
||||||
|
type AuditLogResponse struct {
|
||||||
|
Entries []AuditLogEntry `json:"entries"`
|
||||||
|
Total int `json:"total"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// EscalationResponse is the API response for escalation check
|
||||||
|
type EscalationResponse struct {
|
||||||
|
Results []EscalationResult `json:"results"`
|
||||||
|
TotalChecked int `json:"total_checked"`
|
||||||
|
Escalated int `json:"escalated"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeadlineListResponse is the API response for listing deadlines
|
||||||
|
type DeadlineListResponse struct {
|
||||||
|
Deadlines []DeadlineInfo `json:"deadlines"`
|
||||||
|
Total int `json:"total"`
|
||||||
|
}
|
||||||
193
ai-compliance-sdk/internal/training/models_blocks.go
Normal file
193
ai-compliance-sdk/internal/training/models_blocks.go
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
package training
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Training Block Types (Controls → Schulungsmodule Pipeline)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
// TrainingBlockConfig defines how canonical controls are grouped into training modules
|
||||||
|
type TrainingBlockConfig struct {
|
||||||
|
ID uuid.UUID `json:"id"`
|
||||||
|
TenantID uuid.UUID `json:"tenant_id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description,omitempty"`
|
||||||
|
DomainFilter string `json:"domain_filter,omitempty"`
|
||||||
|
CategoryFilter string `json:"category_filter,omitempty"`
|
||||||
|
SeverityFilter string `json:"severity_filter,omitempty"`
|
||||||
|
TargetAudienceFilter string `json:"target_audience_filter,omitempty"`
|
||||||
|
RegulationArea RegulationArea `json:"regulation_area"`
|
||||||
|
ModuleCodePrefix string `json:"module_code_prefix"`
|
||||||
|
FrequencyType FrequencyType `json:"frequency_type"`
|
||||||
|
DurationMinutes int `json:"duration_minutes"`
|
||||||
|
PassThreshold int `json:"pass_threshold"`
|
||||||
|
MaxControlsPerModule int `json:"max_controls_per_module"`
|
||||||
|
IsActive bool `json:"is_active"`
|
||||||
|
LastGeneratedAt *time.Time `json:"last_generated_at,omitempty"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TrainingBlockControlLink tracks which canonical controls are linked to which module
|
||||||
|
type TrainingBlockControlLink struct {
|
||||||
|
ID uuid.UUID `json:"id"`
|
||||||
|
BlockConfigID uuid.UUID `json:"block_config_id"`
|
||||||
|
ModuleID uuid.UUID `json:"module_id"`
|
||||||
|
ControlID string `json:"control_id"`
|
||||||
|
ControlTitle string `json:"control_title"`
|
||||||
|
ControlObjective string `json:"control_objective"`
|
||||||
|
ControlRequirements []string `json:"control_requirements"`
|
||||||
|
SortOrder int `json:"sort_order"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateBlockConfigRequest is the API request for creating a block config
|
||||||
|
type CreateBlockConfigRequest struct {
|
||||||
|
Name string `json:"name" binding:"required"`
|
||||||
|
Description string `json:"description,omitempty"`
|
||||||
|
DomainFilter string `json:"domain_filter,omitempty"`
|
||||||
|
CategoryFilter string `json:"category_filter,omitempty"`
|
||||||
|
SeverityFilter string `json:"severity_filter,omitempty"`
|
||||||
|
TargetAudienceFilter string `json:"target_audience_filter,omitempty"`
|
||||||
|
RegulationArea RegulationArea `json:"regulation_area" binding:"required"`
|
||||||
|
ModuleCodePrefix string `json:"module_code_prefix" binding:"required"`
|
||||||
|
FrequencyType FrequencyType `json:"frequency_type"`
|
||||||
|
DurationMinutes int `json:"duration_minutes"`
|
||||||
|
PassThreshold int `json:"pass_threshold"`
|
||||||
|
MaxControlsPerModule int `json:"max_controls_per_module"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateBlockConfigRequest is the API request for updating a block config
|
||||||
|
type UpdateBlockConfigRequest struct {
|
||||||
|
Name *string `json:"name,omitempty"`
|
||||||
|
Description *string `json:"description,omitempty"`
|
||||||
|
DomainFilter *string `json:"domain_filter,omitempty"`
|
||||||
|
CategoryFilter *string `json:"category_filter,omitempty"`
|
||||||
|
SeverityFilter *string `json:"severity_filter,omitempty"`
|
||||||
|
TargetAudienceFilter *string `json:"target_audience_filter,omitempty"`
|
||||||
|
MaxControlsPerModule *int `json:"max_controls_per_module,omitempty"`
|
||||||
|
DurationMinutes *int `json:"duration_minutes,omitempty"`
|
||||||
|
PassThreshold *int `json:"pass_threshold,omitempty"`
|
||||||
|
IsActive *bool `json:"is_active,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateBlockRequest is the API request for generating modules from a block config
|
||||||
|
type GenerateBlockRequest struct {
|
||||||
|
Language string `json:"language"`
|
||||||
|
AutoMatrix bool `json:"auto_matrix"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PreviewBlockResponse shows what would be generated without writing to DB
|
||||||
|
type PreviewBlockResponse struct {
|
||||||
|
ControlCount int `json:"control_count"`
|
||||||
|
ModuleCount int `json:"module_count"`
|
||||||
|
Controls []CanonicalControlSummary `json:"controls"`
|
||||||
|
ProposedRoles []string `json:"proposed_roles"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateBlockResponse shows the result of a block generation
|
||||||
|
type GenerateBlockResponse struct {
|
||||||
|
ModulesCreated int `json:"modules_created"`
|
||||||
|
ControlsLinked int `json:"controls_linked"`
|
||||||
|
MatrixEntriesCreated int `json:"matrix_entries_created"`
|
||||||
|
ContentGenerated int `json:"content_generated"`
|
||||||
|
Errors []string `json:"errors,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Interactive Video / Checkpoint Types
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
// NarratorScript is an extended VideoScript with narrator persona and checkpoints
|
||||||
|
type NarratorScript struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
Intro string `json:"intro"`
|
||||||
|
Sections []NarratorSection `json:"sections"`
|
||||||
|
Outro string `json:"outro"`
|
||||||
|
TotalDurationEstimate int `json:"total_duration_estimate"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// NarratorSection is one narrative section with optional checkpoint
|
||||||
|
type NarratorSection struct {
|
||||||
|
Heading string `json:"heading"`
|
||||||
|
NarratorText string `json:"narrator_text"`
|
||||||
|
BulletPoints []string `json:"bullet_points"`
|
||||||
|
Transition string `json:"transition"`
|
||||||
|
Checkpoint *CheckpointDefinition `json:"checkpoint,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckpointDefinition defines a quiz checkpoint within a video
|
||||||
|
type CheckpointDefinition struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
Questions []CheckpointQuestion `json:"questions"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckpointQuestion is a quiz question within a checkpoint
|
||||||
|
type CheckpointQuestion struct {
|
||||||
|
Question string `json:"question"`
|
||||||
|
Options []string `json:"options"`
|
||||||
|
CorrectIndex int `json:"correct_index"`
|
||||||
|
Explanation string `json:"explanation"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Checkpoint is a DB record for a video checkpoint
|
||||||
|
type Checkpoint struct {
|
||||||
|
ID uuid.UUID `json:"id"`
|
||||||
|
ModuleID uuid.UUID `json:"module_id"`
|
||||||
|
CheckpointIndex int `json:"checkpoint_index"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
TimestampSeconds float64 `json:"timestamp_seconds"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckpointProgress tracks a user's progress on a checkpoint
|
||||||
|
type CheckpointProgress struct {
|
||||||
|
ID uuid.UUID `json:"id"`
|
||||||
|
AssignmentID uuid.UUID `json:"assignment_id"`
|
||||||
|
CheckpointID uuid.UUID `json:"checkpoint_id"`
|
||||||
|
Passed bool `json:"passed"`
|
||||||
|
Attempts int `json:"attempts"`
|
||||||
|
LastAttemptAt *time.Time `json:"last_attempt_at,omitempty"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// InteractiveVideoManifest is returned to the frontend player
|
||||||
|
type InteractiveVideoManifest struct {
|
||||||
|
MediaID uuid.UUID `json:"media_id"`
|
||||||
|
StreamURL string `json:"stream_url"`
|
||||||
|
Checkpoints []CheckpointManifestEntry `json:"checkpoints"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckpointManifestEntry is one checkpoint in the manifest
|
||||||
|
type CheckpointManifestEntry struct {
|
||||||
|
CheckpointID uuid.UUID `json:"checkpoint_id"`
|
||||||
|
Index int `json:"index"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
TimestampSeconds float64 `json:"timestamp_seconds"`
|
||||||
|
Questions []CheckpointQuestion `json:"questions"`
|
||||||
|
Progress *CheckpointProgress `json:"progress,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SubmitCheckpointQuizRequest is the API request for submitting a checkpoint quiz
|
||||||
|
type SubmitCheckpointQuizRequest struct {
|
||||||
|
AssignmentID string `json:"assignment_id"`
|
||||||
|
Answers []int `json:"answers"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SubmitCheckpointQuizResponse is the API response for a checkpoint quiz submission
|
||||||
|
type SubmitCheckpointQuizResponse struct {
|
||||||
|
Passed bool `json:"passed"`
|
||||||
|
Score float64 `json:"score"`
|
||||||
|
Feedback []CheckpointQuizFeedback `json:"feedback"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckpointQuizFeedback is feedback for a single question
|
||||||
|
type CheckpointQuizFeedback struct {
|
||||||
|
Question string `json:"question"`
|
||||||
|
Correct bool `json:"correct"`
|
||||||
|
Explanation string `json:"explanation"`
|
||||||
|
}
|
||||||
276
ai-compliance-sdk/internal/training/models_core.go
Normal file
276
ai-compliance-sdk/internal/training/models_core.go
Normal file
@@ -0,0 +1,276 @@
|
|||||||
|
package training
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Main Entities
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
// TrainingModule represents a compliance training module
|
||||||
|
type TrainingModule struct {
|
||||||
|
ID uuid.UUID `json:"id"`
|
||||||
|
TenantID uuid.UUID `json:"tenant_id"`
|
||||||
|
AcademyCourseID *uuid.UUID `json:"academy_course_id,omitempty"`
|
||||||
|
ModuleCode string `json:"module_code"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Description string `json:"description,omitempty"`
|
||||||
|
RegulationArea RegulationArea `json:"regulation_area"`
|
||||||
|
NIS2Relevant bool `json:"nis2_relevant"`
|
||||||
|
ISOControls []string `json:"iso_controls"` // JSONB
|
||||||
|
FrequencyType FrequencyType `json:"frequency_type"`
|
||||||
|
ValidityDays int `json:"validity_days"`
|
||||||
|
RiskWeight float64 `json:"risk_weight"`
|
||||||
|
ContentType string `json:"content_type"`
|
||||||
|
DurationMinutes int `json:"duration_minutes"`
|
||||||
|
PassThreshold int `json:"pass_threshold"`
|
||||||
|
IsActive bool `json:"is_active"`
|
||||||
|
SortOrder int `json:"sort_order"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TrainingMatrixEntry represents a role-to-module mapping in the CTM
|
||||||
|
type TrainingMatrixEntry struct {
|
||||||
|
ID uuid.UUID `json:"id"`
|
||||||
|
TenantID uuid.UUID `json:"tenant_id"`
|
||||||
|
RoleCode string `json:"role_code"`
|
||||||
|
ModuleID uuid.UUID `json:"module_id"`
|
||||||
|
IsMandatory bool `json:"is_mandatory"`
|
||||||
|
Priority int `json:"priority"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
// Joined fields (optional, populated in queries)
|
||||||
|
ModuleCode string `json:"module_code,omitempty"`
|
||||||
|
ModuleTitle string `json:"module_title,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TrainingAssignment represents a user's training assignment
|
||||||
|
type TrainingAssignment struct {
|
||||||
|
ID uuid.UUID `json:"id"`
|
||||||
|
TenantID uuid.UUID `json:"tenant_id"`
|
||||||
|
ModuleID uuid.UUID `json:"module_id"`
|
||||||
|
UserID uuid.UUID `json:"user_id"`
|
||||||
|
UserName string `json:"user_name"`
|
||||||
|
UserEmail string `json:"user_email"`
|
||||||
|
RoleCode string `json:"role_code,omitempty"`
|
||||||
|
TriggerType TriggerType `json:"trigger_type"`
|
||||||
|
TriggerEvent string `json:"trigger_event,omitempty"`
|
||||||
|
Status AssignmentStatus `json:"status"`
|
||||||
|
ProgressPercent int `json:"progress_percent"`
|
||||||
|
QuizScore *float64 `json:"quiz_score,omitempty"`
|
||||||
|
QuizPassed *bool `json:"quiz_passed,omitempty"`
|
||||||
|
QuizAttempts int `json:"quiz_attempts"`
|
||||||
|
StartedAt *time.Time `json:"started_at,omitempty"`
|
||||||
|
CompletedAt *time.Time `json:"completed_at,omitempty"`
|
||||||
|
Deadline time.Time `json:"deadline"`
|
||||||
|
CertificateID *uuid.UUID `json:"certificate_id,omitempty"`
|
||||||
|
EscalationLevel int `json:"escalation_level"`
|
||||||
|
LastEscalationAt *time.Time `json:"last_escalation_at,omitempty"`
|
||||||
|
EnrollmentID *uuid.UUID `json:"enrollment_id,omitempty"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
// Joined fields
|
||||||
|
ModuleCode string `json:"module_code,omitempty"`
|
||||||
|
ModuleTitle string `json:"module_title,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// QuizQuestion represents a persistent quiz question for a module
|
||||||
|
type QuizQuestion struct {
|
||||||
|
ID uuid.UUID `json:"id"`
|
||||||
|
ModuleID uuid.UUID `json:"module_id"`
|
||||||
|
Question string `json:"question"`
|
||||||
|
Options []string `json:"options"` // JSONB
|
||||||
|
CorrectIndex int `json:"correct_index"`
|
||||||
|
Explanation string `json:"explanation,omitempty"`
|
||||||
|
Difficulty Difficulty `json:"difficulty"`
|
||||||
|
IsActive bool `json:"is_active"`
|
||||||
|
SortOrder int `json:"sort_order"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// QuizAttempt represents a single quiz attempt by a user
|
||||||
|
type QuizAttempt struct {
|
||||||
|
ID uuid.UUID `json:"id"`
|
||||||
|
AssignmentID uuid.UUID `json:"assignment_id"`
|
||||||
|
UserID uuid.UUID `json:"user_id"`
|
||||||
|
Answers []QuizAnswer `json:"answers"` // JSONB
|
||||||
|
Score float64 `json:"score"`
|
||||||
|
Passed bool `json:"passed"`
|
||||||
|
CorrectCount int `json:"correct_count"`
|
||||||
|
TotalCount int `json:"total_count"`
|
||||||
|
DurationSeconds *int `json:"duration_seconds,omitempty"`
|
||||||
|
AttemptedAt time.Time `json:"attempted_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// QuizAnswer represents a single answer within a quiz attempt
|
||||||
|
type QuizAnswer struct {
|
||||||
|
QuestionID uuid.UUID `json:"question_id"`
|
||||||
|
SelectedIndex int `json:"selected_index"`
|
||||||
|
Correct bool `json:"correct"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AuditLogEntry represents an entry in the training audit trail
|
||||||
|
type AuditLogEntry struct {
|
||||||
|
ID uuid.UUID `json:"id"`
|
||||||
|
TenantID uuid.UUID `json:"tenant_id"`
|
||||||
|
UserID *uuid.UUID `json:"user_id,omitempty"`
|
||||||
|
Action AuditAction `json:"action"`
|
||||||
|
EntityType AuditEntityType `json:"entity_type"`
|
||||||
|
EntityID *uuid.UUID `json:"entity_id,omitempty"`
|
||||||
|
Details map[string]interface{} `json:"details"` // JSONB
|
||||||
|
IPAddress string `json:"ip_address,omitempty"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ModuleContent represents LLM-generated or manual content for a module
|
||||||
|
type ModuleContent struct {
|
||||||
|
ID uuid.UUID `json:"id"`
|
||||||
|
ModuleID uuid.UUID `json:"module_id"`
|
||||||
|
Version int `json:"version"`
|
||||||
|
ContentFormat ContentFormat `json:"content_format"`
|
||||||
|
ContentBody string `json:"content_body"`
|
||||||
|
Summary string `json:"summary,omitempty"`
|
||||||
|
GeneratedBy string `json:"generated_by,omitempty"`
|
||||||
|
LLMModel string `json:"llm_model,omitempty"`
|
||||||
|
IsPublished bool `json:"is_published"`
|
||||||
|
ReviewedBy *uuid.UUID `json:"reviewed_by,omitempty"`
|
||||||
|
ReviewedAt *time.Time `json:"reviewed_at,omitempty"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TrainingStats contains aggregated training metrics
|
||||||
|
type TrainingStats struct {
|
||||||
|
TotalModules int `json:"total_modules"`
|
||||||
|
TotalAssignments int `json:"total_assignments"`
|
||||||
|
CompletionRate float64 `json:"completion_rate"`
|
||||||
|
OverdueCount int `json:"overdue_count"`
|
||||||
|
PendingCount int `json:"pending_count"`
|
||||||
|
InProgressCount int `json:"in_progress_count"`
|
||||||
|
CompletedCount int `json:"completed_count"`
|
||||||
|
AvgQuizScore float64 `json:"avg_quiz_score"`
|
||||||
|
AvgCompletionDays float64 `json:"avg_completion_days"`
|
||||||
|
UpcomingDeadlines int `json:"upcoming_deadlines"` // within 7 days
|
||||||
|
}
|
||||||
|
|
||||||
|
// ComplianceGap represents a missing or overdue training requirement
|
||||||
|
type ComplianceGap struct {
|
||||||
|
ModuleID uuid.UUID `json:"module_id"`
|
||||||
|
ModuleCode string `json:"module_code"`
|
||||||
|
ModuleTitle string `json:"module_title"`
|
||||||
|
RegulationArea RegulationArea `json:"regulation_area"`
|
||||||
|
RoleCode string `json:"role_code"`
|
||||||
|
IsMandatory bool `json:"is_mandatory"`
|
||||||
|
AssignmentID *uuid.UUID `json:"assignment_id,omitempty"`
|
||||||
|
Status string `json:"status"` // "missing", "overdue", "expired"
|
||||||
|
Deadline *time.Time `json:"deadline,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// EscalationResult represents the result of an escalation check
|
||||||
|
type EscalationResult struct {
|
||||||
|
AssignmentID uuid.UUID `json:"assignment_id"`
|
||||||
|
UserID uuid.UUID `json:"user_id"`
|
||||||
|
UserName string `json:"user_name"`
|
||||||
|
UserEmail string `json:"user_email"`
|
||||||
|
ModuleTitle string `json:"module_title"`
|
||||||
|
PreviousLevel int `json:"previous_level"`
|
||||||
|
NewLevel int `json:"new_level"`
|
||||||
|
DaysOverdue int `json:"days_overdue"`
|
||||||
|
EscalationLabel string `json:"escalation_label"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeadlineInfo represents upcoming deadline information
|
||||||
|
type DeadlineInfo struct {
|
||||||
|
AssignmentID uuid.UUID `json:"assignment_id"`
|
||||||
|
ModuleCode string `json:"module_code"`
|
||||||
|
ModuleTitle string `json:"module_title"`
|
||||||
|
UserID uuid.UUID `json:"user_id"`
|
||||||
|
UserName string `json:"user_name"`
|
||||||
|
Deadline time.Time `json:"deadline"`
|
||||||
|
DaysLeft int `json:"days_left"`
|
||||||
|
Status AssignmentStatus `json:"status"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Filter Types
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
// ModuleFilters defines filters for listing modules
|
||||||
|
type ModuleFilters struct {
|
||||||
|
RegulationArea RegulationArea
|
||||||
|
FrequencyType FrequencyType
|
||||||
|
IsActive *bool
|
||||||
|
NIS2Relevant *bool
|
||||||
|
Search string
|
||||||
|
Limit int
|
||||||
|
Offset int
|
||||||
|
}
|
||||||
|
|
||||||
|
// AssignmentFilters defines filters for listing assignments
|
||||||
|
type AssignmentFilters struct {
|
||||||
|
ModuleID *uuid.UUID
|
||||||
|
UserID *uuid.UUID
|
||||||
|
RoleCode string
|
||||||
|
Status AssignmentStatus
|
||||||
|
Overdue *bool
|
||||||
|
Limit int
|
||||||
|
Offset int
|
||||||
|
}
|
||||||
|
|
||||||
|
// AuditLogFilters defines filters for listing audit log entries
|
||||||
|
type AuditLogFilters struct {
|
||||||
|
UserID *uuid.UUID
|
||||||
|
Action AuditAction
|
||||||
|
EntityType AuditEntityType
|
||||||
|
Limit int
|
||||||
|
Offset int
|
||||||
|
}
|
||||||
|
|
||||||
|
// CanonicalControlSummary is a lightweight view on canonical_controls for the training pipeline
|
||||||
|
type CanonicalControlSummary struct {
|
||||||
|
ControlID string `json:"control_id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Objective string `json:"objective"`
|
||||||
|
Rationale string `json:"rationale"`
|
||||||
|
Requirements []string `json:"requirements"`
|
||||||
|
Severity string `json:"severity"`
|
||||||
|
Category string `json:"category"`
|
||||||
|
TargetAudience string `json:"target_audience"`
|
||||||
|
Tags []string `json:"tags"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CanonicalControlMeta provides aggregated metadata about canonical controls
|
||||||
|
type CanonicalControlMeta struct {
|
||||||
|
Domains []DomainCount `json:"domains"`
|
||||||
|
Categories []CategoryCount `json:"categories"`
|
||||||
|
Audiences []AudienceCount `json:"audiences"`
|
||||||
|
Total int `json:"total"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DomainCount is a domain with its control count
|
||||||
|
type DomainCount struct {
|
||||||
|
Domain string `json:"domain"`
|
||||||
|
Count int `json:"count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CategoryCount is a category with its control count
|
||||||
|
type CategoryCount struct {
|
||||||
|
Category string `json:"category"`
|
||||||
|
Count int `json:"count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AudienceCount is a target audience with its control count
|
||||||
|
type AudienceCount struct {
|
||||||
|
Audience string `json:"audience"`
|
||||||
|
Count int `json:"count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// BulkResult holds the result of a bulk generation operation
|
||||||
|
type BulkResult struct {
|
||||||
|
Generated int `json:"generated"`
|
||||||
|
Skipped int `json:"skipped"`
|
||||||
|
Errors []string `json:"errors"`
|
||||||
|
}
|
||||||
162
ai-compliance-sdk/internal/training/models_enums.go
Normal file
162
ai-compliance-sdk/internal/training/models_enums.go
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
package training
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Constants / Enums
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
// RegulationArea represents a compliance regulation area
|
||||||
|
type RegulationArea string
|
||||||
|
|
||||||
|
const (
|
||||||
|
RegulationDSGVO RegulationArea = "dsgvo"
|
||||||
|
RegulationNIS2 RegulationArea = "nis2"
|
||||||
|
RegulationISO27001 RegulationArea = "iso27001"
|
||||||
|
RegulationAIAct RegulationArea = "ai_act"
|
||||||
|
RegulationGeschGehG RegulationArea = "geschgehg"
|
||||||
|
RegulationHinSchG RegulationArea = "hinschg"
|
||||||
|
)
|
||||||
|
|
||||||
|
// FrequencyType represents the training frequency
|
||||||
|
type FrequencyType string
|
||||||
|
|
||||||
|
const (
|
||||||
|
FrequencyOnboarding FrequencyType = "onboarding"
|
||||||
|
FrequencyAnnual FrequencyType = "annual"
|
||||||
|
FrequencyEventTrigger FrequencyType = "event_trigger"
|
||||||
|
FrequencyMicro FrequencyType = "micro"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AssignmentStatus represents the status of a training assignment
|
||||||
|
type AssignmentStatus string
|
||||||
|
|
||||||
|
const (
|
||||||
|
AssignmentStatusPending AssignmentStatus = "pending"
|
||||||
|
AssignmentStatusInProgress AssignmentStatus = "in_progress"
|
||||||
|
AssignmentStatusCompleted AssignmentStatus = "completed"
|
||||||
|
AssignmentStatusOverdue AssignmentStatus = "overdue"
|
||||||
|
AssignmentStatusExpired AssignmentStatus = "expired"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TriggerType represents how a training was assigned
|
||||||
|
type TriggerType string
|
||||||
|
|
||||||
|
const (
|
||||||
|
TriggerOnboarding TriggerType = "onboarding"
|
||||||
|
TriggerAnnual TriggerType = "annual"
|
||||||
|
TriggerEvent TriggerType = "event"
|
||||||
|
TriggerManual TriggerType = "manual"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ContentFormat represents the format of module content
|
||||||
|
type ContentFormat string
|
||||||
|
|
||||||
|
const (
|
||||||
|
ContentFormatMarkdown ContentFormat = "markdown"
|
||||||
|
ContentFormatHTML ContentFormat = "html"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Difficulty represents the difficulty level of a quiz question
|
||||||
|
type Difficulty string
|
||||||
|
|
||||||
|
const (
|
||||||
|
DifficultyEasy Difficulty = "easy"
|
||||||
|
DifficultyMedium Difficulty = "medium"
|
||||||
|
DifficultyHard Difficulty = "hard"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AuditAction represents an action in the audit trail
|
||||||
|
type AuditAction string
|
||||||
|
|
||||||
|
const (
|
||||||
|
AuditActionAssigned AuditAction = "assigned"
|
||||||
|
AuditActionStarted AuditAction = "started"
|
||||||
|
AuditActionCompleted AuditAction = "completed"
|
||||||
|
AuditActionQuizSubmitted AuditAction = "quiz_submitted"
|
||||||
|
AuditActionEscalated AuditAction = "escalated"
|
||||||
|
AuditActionCertificateIssued AuditAction = "certificate_issued"
|
||||||
|
AuditActionContentGenerated AuditAction = "content_generated"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AuditEntityType represents the type of entity in audit log
|
||||||
|
type AuditEntityType string
|
||||||
|
|
||||||
|
const (
|
||||||
|
AuditEntityAssignment AuditEntityType = "assignment"
|
||||||
|
AuditEntityModule AuditEntityType = "module"
|
||||||
|
AuditEntityQuiz AuditEntityType = "quiz"
|
||||||
|
AuditEntityCertificate AuditEntityType = "certificate"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Role Constants
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const (
|
||||||
|
RoleR1 = "R1" // Geschaeftsfuehrung
|
||||||
|
RoleR2 = "R2" // IT-Leitung
|
||||||
|
RoleR3 = "R3" // DSB
|
||||||
|
RoleR4 = "R4" // ISB
|
||||||
|
RoleR5 = "R5" // HR
|
||||||
|
RoleR6 = "R6" // Einkauf
|
||||||
|
RoleR7 = "R7" // Fachabteilung
|
||||||
|
RoleR8 = "R8" // IT-Admin
|
||||||
|
RoleR9 = "R9" // Alle Mitarbeiter
|
||||||
|
RoleR10 = "R10" // Behoerden / Oeffentlicher Dienst
|
||||||
|
)
|
||||||
|
|
||||||
|
// RoleLabels maps role codes to human-readable labels
|
||||||
|
var RoleLabels = map[string]string{
|
||||||
|
RoleR1: "Geschaeftsfuehrung",
|
||||||
|
RoleR2: "IT-Leitung",
|
||||||
|
RoleR3: "Datenschutzbeauftragter",
|
||||||
|
RoleR4: "Informationssicherheitsbeauftragter",
|
||||||
|
RoleR5: "HR / Personal",
|
||||||
|
RoleR6: "Einkauf / Beschaffung",
|
||||||
|
RoleR7: "Fachabteilung",
|
||||||
|
RoleR8: "IT-Administration",
|
||||||
|
RoleR9: "Alle Mitarbeiter",
|
||||||
|
RoleR10: "Behoerden / Oeffentlicher Dienst",
|
||||||
|
}
|
||||||
|
|
||||||
|
// NIS2RoleMapping maps internal roles to NIS2 levels
|
||||||
|
var NIS2RoleMapping = map[string]string{
|
||||||
|
RoleR1: "N1", // Geschaeftsfuehrung
|
||||||
|
RoleR2: "N2", // IT-Leitung
|
||||||
|
RoleR3: "N3", // DSB
|
||||||
|
RoleR4: "N3", // ISB
|
||||||
|
RoleR5: "N4", // HR
|
||||||
|
RoleR6: "N4", // Einkauf
|
||||||
|
RoleR7: "N5", // Fachabteilung
|
||||||
|
RoleR8: "N2", // IT-Admin
|
||||||
|
RoleR9: "N5", // Alle Mitarbeiter
|
||||||
|
RoleR10: "N4", // Behoerden
|
||||||
|
}
|
||||||
|
|
||||||
|
// TargetAudienceRoleMapping maps canonical control target_audience values to CTM roles
|
||||||
|
var TargetAudienceRoleMapping = map[string][]string{
|
||||||
|
"enterprise": {RoleR1, RoleR4, RoleR5, RoleR6, RoleR7, RoleR9},
|
||||||
|
"authority": {RoleR10},
|
||||||
|
"provider": {RoleR2, RoleR8},
|
||||||
|
"all": {RoleR1, RoleR2, RoleR3, RoleR4, RoleR5, RoleR6, RoleR7, RoleR8, RoleR9, RoleR10},
|
||||||
|
}
|
||||||
|
|
||||||
|
// CategoryRoleMapping provides additional role hints based on control category
|
||||||
|
var CategoryRoleMapping = map[string][]string{
|
||||||
|
"encryption": {RoleR2, RoleR8},
|
||||||
|
"authentication": {RoleR2, RoleR8, RoleR9},
|
||||||
|
"network": {RoleR2, RoleR8},
|
||||||
|
"data_protection": {RoleR3, RoleR5, RoleR9},
|
||||||
|
"logging": {RoleR2, RoleR4, RoleR8},
|
||||||
|
"incident": {RoleR1, RoleR4},
|
||||||
|
"continuity": {RoleR1, RoleR2, RoleR4},
|
||||||
|
"compliance": {RoleR1, RoleR3, RoleR4},
|
||||||
|
"supply_chain": {RoleR6},
|
||||||
|
"physical": {RoleR7},
|
||||||
|
"personnel": {RoleR5, RoleR9},
|
||||||
|
"application": {RoleR8},
|
||||||
|
"system": {RoleR2, RoleR8},
|
||||||
|
"risk": {RoleR1, RoleR4},
|
||||||
|
"governance": {RoleR1, RoleR4},
|
||||||
|
"hardware": {RoleR2, RoleR8},
|
||||||
|
"identity": {RoleR2, RoleR3, RoleR8},
|
||||||
|
}
|
||||||
@@ -1,793 +0,0 @@
|
|||||||
package workshop
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"crypto/rand"
|
|
||||||
"encoding/base32"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/google/uuid"
|
|
||||||
"github.com/jackc/pgx/v5"
|
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Store handles workshop session data persistence
|
|
||||||
type Store struct {
|
|
||||||
pool *pgxpool.Pool
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewStore creates a new workshop store
|
|
||||||
func NewStore(pool *pgxpool.Pool) *Store {
|
|
||||||
return &Store{pool: pool}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Session CRUD Operations
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
// CreateSession creates a new workshop session
|
|
||||||
func (s *Store) CreateSession(ctx context.Context, session *Session) error {
|
|
||||||
session.ID = uuid.New()
|
|
||||||
session.CreatedAt = time.Now().UTC()
|
|
||||||
session.UpdatedAt = session.CreatedAt
|
|
||||||
if session.Status == "" {
|
|
||||||
session.Status = SessionStatusDraft
|
|
||||||
}
|
|
||||||
if session.JoinCode == "" {
|
|
||||||
session.JoinCode = generateJoinCode()
|
|
||||||
}
|
|
||||||
|
|
||||||
settings, _ := json.Marshal(session.Settings)
|
|
||||||
|
|
||||||
_, err := s.pool.Exec(ctx, `
|
|
||||||
INSERT INTO workshop_sessions (
|
|
||||||
id, tenant_id, namespace_id,
|
|
||||||
title, description, session_type, status,
|
|
||||||
wizard_schema, current_step, total_steps,
|
|
||||||
assessment_id, roadmap_id, portfolio_id,
|
|
||||||
scheduled_start, scheduled_end, actual_start, actual_end,
|
|
||||||
join_code, require_auth, allow_anonymous,
|
|
||||||
settings,
|
|
||||||
created_at, updated_at, created_by
|
|
||||||
) VALUES (
|
|
||||||
$1, $2, $3,
|
|
||||||
$4, $5, $6, $7,
|
|
||||||
$8, $9, $10,
|
|
||||||
$11, $12, $13,
|
|
||||||
$14, $15, $16, $17,
|
|
||||||
$18, $19, $20,
|
|
||||||
$21,
|
|
||||||
$22, $23, $24
|
|
||||||
)
|
|
||||||
`,
|
|
||||||
session.ID, session.TenantID, session.NamespaceID,
|
|
||||||
session.Title, session.Description, session.SessionType, string(session.Status),
|
|
||||||
session.WizardSchema, session.CurrentStep, session.TotalSteps,
|
|
||||||
session.AssessmentID, session.RoadmapID, session.PortfolioID,
|
|
||||||
session.ScheduledStart, session.ScheduledEnd, session.ActualStart, session.ActualEnd,
|
|
||||||
session.JoinCode, session.RequireAuth, session.AllowAnonymous,
|
|
||||||
settings,
|
|
||||||
session.CreatedAt, session.UpdatedAt, session.CreatedBy,
|
|
||||||
)
|
|
||||||
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetSession retrieves a session by ID
|
|
||||||
func (s *Store) GetSession(ctx context.Context, id uuid.UUID) (*Session, error) {
|
|
||||||
var session Session
|
|
||||||
var status string
|
|
||||||
var settings []byte
|
|
||||||
|
|
||||||
err := s.pool.QueryRow(ctx, `
|
|
||||||
SELECT
|
|
||||||
id, tenant_id, namespace_id,
|
|
||||||
title, description, session_type, status,
|
|
||||||
wizard_schema, current_step, total_steps,
|
|
||||||
assessment_id, roadmap_id, portfolio_id,
|
|
||||||
scheduled_start, scheduled_end, actual_start, actual_end,
|
|
||||||
join_code, require_auth, allow_anonymous,
|
|
||||||
settings,
|
|
||||||
created_at, updated_at, created_by
|
|
||||||
FROM workshop_sessions WHERE id = $1
|
|
||||||
`, id).Scan(
|
|
||||||
&session.ID, &session.TenantID, &session.NamespaceID,
|
|
||||||
&session.Title, &session.Description, &session.SessionType, &status,
|
|
||||||
&session.WizardSchema, &session.CurrentStep, &session.TotalSteps,
|
|
||||||
&session.AssessmentID, &session.RoadmapID, &session.PortfolioID,
|
|
||||||
&session.ScheduledStart, &session.ScheduledEnd, &session.ActualStart, &session.ActualEnd,
|
|
||||||
&session.JoinCode, &session.RequireAuth, &session.AllowAnonymous,
|
|
||||||
&settings,
|
|
||||||
&session.CreatedAt, &session.UpdatedAt, &session.CreatedBy,
|
|
||||||
)
|
|
||||||
|
|
||||||
if err == pgx.ErrNoRows {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
session.Status = SessionStatus(status)
|
|
||||||
json.Unmarshal(settings, &session.Settings)
|
|
||||||
|
|
||||||
return &session, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetSessionByJoinCode retrieves a session by its join code
|
|
||||||
func (s *Store) GetSessionByJoinCode(ctx context.Context, code string) (*Session, error) {
|
|
||||||
var id uuid.UUID
|
|
||||||
err := s.pool.QueryRow(ctx,
|
|
||||||
"SELECT id FROM workshop_sessions WHERE join_code = $1",
|
|
||||||
strings.ToUpper(code),
|
|
||||||
).Scan(&id)
|
|
||||||
|
|
||||||
if err == pgx.ErrNoRows {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return s.GetSession(ctx, id)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ListSessions lists sessions for a tenant with optional filters
|
|
||||||
func (s *Store) ListSessions(ctx context.Context, tenantID uuid.UUID, filters *SessionFilters) ([]Session, error) {
|
|
||||||
query := `
|
|
||||||
SELECT
|
|
||||||
id, tenant_id, namespace_id,
|
|
||||||
title, description, session_type, status,
|
|
||||||
wizard_schema, current_step, total_steps,
|
|
||||||
assessment_id, roadmap_id, portfolio_id,
|
|
||||||
scheduled_start, scheduled_end, actual_start, actual_end,
|
|
||||||
join_code, require_auth, allow_anonymous,
|
|
||||||
settings,
|
|
||||||
created_at, updated_at, created_by
|
|
||||||
FROM workshop_sessions WHERE tenant_id = $1`
|
|
||||||
|
|
||||||
args := []interface{}{tenantID}
|
|
||||||
argIdx := 2
|
|
||||||
|
|
||||||
if filters != nil {
|
|
||||||
if filters.Status != "" {
|
|
||||||
query += fmt.Sprintf(" AND status = $%d", argIdx)
|
|
||||||
args = append(args, string(filters.Status))
|
|
||||||
argIdx++
|
|
||||||
}
|
|
||||||
if filters.SessionType != "" {
|
|
||||||
query += fmt.Sprintf(" AND session_type = $%d", argIdx)
|
|
||||||
args = append(args, filters.SessionType)
|
|
||||||
argIdx++
|
|
||||||
}
|
|
||||||
if filters.AssessmentID != nil {
|
|
||||||
query += fmt.Sprintf(" AND assessment_id = $%d", argIdx)
|
|
||||||
args = append(args, *filters.AssessmentID)
|
|
||||||
argIdx++
|
|
||||||
}
|
|
||||||
if filters.CreatedBy != nil {
|
|
||||||
query += fmt.Sprintf(" AND created_by = $%d", argIdx)
|
|
||||||
args = append(args, *filters.CreatedBy)
|
|
||||||
argIdx++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
query += " ORDER BY created_at DESC"
|
|
||||||
|
|
||||||
if filters != nil && filters.Limit > 0 {
|
|
||||||
query += fmt.Sprintf(" LIMIT $%d", argIdx)
|
|
||||||
args = append(args, filters.Limit)
|
|
||||||
argIdx++
|
|
||||||
|
|
||||||
if filters.Offset > 0 {
|
|
||||||
query += fmt.Sprintf(" OFFSET $%d", argIdx)
|
|
||||||
args = append(args, filters.Offset)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
rows, err := s.pool.Query(ctx, query, args...)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
var sessions []Session
|
|
||||||
for rows.Next() {
|
|
||||||
var session Session
|
|
||||||
var status string
|
|
||||||
var settings []byte
|
|
||||||
|
|
||||||
err := rows.Scan(
|
|
||||||
&session.ID, &session.TenantID, &session.NamespaceID,
|
|
||||||
&session.Title, &session.Description, &session.SessionType, &status,
|
|
||||||
&session.WizardSchema, &session.CurrentStep, &session.TotalSteps,
|
|
||||||
&session.AssessmentID, &session.RoadmapID, &session.PortfolioID,
|
|
||||||
&session.ScheduledStart, &session.ScheduledEnd, &session.ActualStart, &session.ActualEnd,
|
|
||||||
&session.JoinCode, &session.RequireAuth, &session.AllowAnonymous,
|
|
||||||
&settings,
|
|
||||||
&session.CreatedAt, &session.UpdatedAt, &session.CreatedBy,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
session.Status = SessionStatus(status)
|
|
||||||
json.Unmarshal(settings, &session.Settings)
|
|
||||||
|
|
||||||
sessions = append(sessions, session)
|
|
||||||
}
|
|
||||||
|
|
||||||
return sessions, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// UpdateSession updates a session
|
|
||||||
func (s *Store) UpdateSession(ctx context.Context, session *Session) error {
|
|
||||||
session.UpdatedAt = time.Now().UTC()
|
|
||||||
|
|
||||||
settings, _ := json.Marshal(session.Settings)
|
|
||||||
|
|
||||||
_, err := s.pool.Exec(ctx, `
|
|
||||||
UPDATE workshop_sessions SET
|
|
||||||
title = $2, description = $3, status = $4,
|
|
||||||
wizard_schema = $5, current_step = $6, total_steps = $7,
|
|
||||||
scheduled_start = $8, scheduled_end = $9,
|
|
||||||
actual_start = $10, actual_end = $11,
|
|
||||||
require_auth = $12, allow_anonymous = $13,
|
|
||||||
settings = $14,
|
|
||||||
updated_at = $15
|
|
||||||
WHERE id = $1
|
|
||||||
`,
|
|
||||||
session.ID, session.Title, session.Description, string(session.Status),
|
|
||||||
session.WizardSchema, session.CurrentStep, session.TotalSteps,
|
|
||||||
session.ScheduledStart, session.ScheduledEnd,
|
|
||||||
session.ActualStart, session.ActualEnd,
|
|
||||||
session.RequireAuth, session.AllowAnonymous,
|
|
||||||
settings,
|
|
||||||
session.UpdatedAt,
|
|
||||||
)
|
|
||||||
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// UpdateSessionStatus updates only the session status
|
|
||||||
func (s *Store) UpdateSessionStatus(ctx context.Context, id uuid.UUID, status SessionStatus) error {
|
|
||||||
now := time.Now().UTC()
|
|
||||||
|
|
||||||
query := "UPDATE workshop_sessions SET status = $2, updated_at = $3"
|
|
||||||
|
|
||||||
if status == SessionStatusActive {
|
|
||||||
query += ", actual_start = COALESCE(actual_start, $3)"
|
|
||||||
} else if status == SessionStatusCompleted || status == SessionStatusCancelled {
|
|
||||||
query += ", actual_end = $3"
|
|
||||||
}
|
|
||||||
|
|
||||||
query += " WHERE id = $1"
|
|
||||||
|
|
||||||
_, err := s.pool.Exec(ctx, query, id, string(status), now)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// AdvanceStep advances the session to the next step
|
|
||||||
func (s *Store) AdvanceStep(ctx context.Context, id uuid.UUID) error {
|
|
||||||
_, err := s.pool.Exec(ctx, `
|
|
||||||
UPDATE workshop_sessions SET
|
|
||||||
current_step = current_step + 1,
|
|
||||||
updated_at = NOW()
|
|
||||||
WHERE id = $1 AND current_step < total_steps
|
|
||||||
`, id)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// DeleteSession deletes a session and its related data
|
|
||||||
func (s *Store) DeleteSession(ctx context.Context, id uuid.UUID) error {
|
|
||||||
// Delete in order: comments, responses, step_progress, participants, session
|
|
||||||
_, err := s.pool.Exec(ctx, "DELETE FROM workshop_comments WHERE session_id = $1", id)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
_, err = s.pool.Exec(ctx, "DELETE FROM workshop_responses WHERE session_id = $1", id)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
_, err = s.pool.Exec(ctx, "DELETE FROM workshop_step_progress WHERE session_id = $1", id)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
_, err = s.pool.Exec(ctx, "DELETE FROM workshop_participants WHERE session_id = $1", id)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
_, err = s.pool.Exec(ctx, "DELETE FROM workshop_sessions WHERE id = $1", id)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Participant Operations
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
// AddParticipant adds a participant to a session
|
|
||||||
func (s *Store) AddParticipant(ctx context.Context, p *Participant) error {
|
|
||||||
p.ID = uuid.New()
|
|
||||||
p.JoinedAt = time.Now().UTC()
|
|
||||||
p.IsActive = true
|
|
||||||
now := p.JoinedAt
|
|
||||||
p.LastActiveAt = &now
|
|
||||||
|
|
||||||
_, err := s.pool.Exec(ctx, `
|
|
||||||
INSERT INTO workshop_participants (
|
|
||||||
id, session_id, user_id,
|
|
||||||
name, email, role, department,
|
|
||||||
is_active, last_active_at, joined_at, left_at,
|
|
||||||
can_edit, can_comment, can_approve
|
|
||||||
) VALUES (
|
|
||||||
$1, $2, $3,
|
|
||||||
$4, $5, $6, $7,
|
|
||||||
$8, $9, $10, $11,
|
|
||||||
$12, $13, $14
|
|
||||||
)
|
|
||||||
`,
|
|
||||||
p.ID, p.SessionID, p.UserID,
|
|
||||||
p.Name, p.Email, string(p.Role), p.Department,
|
|
||||||
p.IsActive, p.LastActiveAt, p.JoinedAt, p.LeftAt,
|
|
||||||
p.CanEdit, p.CanComment, p.CanApprove,
|
|
||||||
)
|
|
||||||
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetParticipant retrieves a participant by ID
|
|
||||||
func (s *Store) GetParticipant(ctx context.Context, id uuid.UUID) (*Participant, error) {
|
|
||||||
var p Participant
|
|
||||||
var role string
|
|
||||||
|
|
||||||
err := s.pool.QueryRow(ctx, `
|
|
||||||
SELECT
|
|
||||||
id, session_id, user_id,
|
|
||||||
name, email, role, department,
|
|
||||||
is_active, last_active_at, joined_at, left_at,
|
|
||||||
can_edit, can_comment, can_approve
|
|
||||||
FROM workshop_participants WHERE id = $1
|
|
||||||
`, id).Scan(
|
|
||||||
&p.ID, &p.SessionID, &p.UserID,
|
|
||||||
&p.Name, &p.Email, &role, &p.Department,
|
|
||||||
&p.IsActive, &p.LastActiveAt, &p.JoinedAt, &p.LeftAt,
|
|
||||||
&p.CanEdit, &p.CanComment, &p.CanApprove,
|
|
||||||
)
|
|
||||||
|
|
||||||
if err == pgx.ErrNoRows {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
p.Role = ParticipantRole(role)
|
|
||||||
return &p, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ListParticipants lists participants for a session
|
|
||||||
func (s *Store) ListParticipants(ctx context.Context, sessionID uuid.UUID) ([]Participant, error) {
|
|
||||||
rows, err := s.pool.Query(ctx, `
|
|
||||||
SELECT
|
|
||||||
id, session_id, user_id,
|
|
||||||
name, email, role, department,
|
|
||||||
is_active, last_active_at, joined_at, left_at,
|
|
||||||
can_edit, can_comment, can_approve
|
|
||||||
FROM workshop_participants WHERE session_id = $1
|
|
||||||
ORDER BY joined_at ASC
|
|
||||||
`, sessionID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
var participants []Participant
|
|
||||||
for rows.Next() {
|
|
||||||
var p Participant
|
|
||||||
var role string
|
|
||||||
|
|
||||||
err := rows.Scan(
|
|
||||||
&p.ID, &p.SessionID, &p.UserID,
|
|
||||||
&p.Name, &p.Email, &role, &p.Department,
|
|
||||||
&p.IsActive, &p.LastActiveAt, &p.JoinedAt, &p.LeftAt,
|
|
||||||
&p.CanEdit, &p.CanComment, &p.CanApprove,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
p.Role = ParticipantRole(role)
|
|
||||||
participants = append(participants, p)
|
|
||||||
}
|
|
||||||
|
|
||||||
return participants, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// UpdateParticipantActivity updates the last active timestamp
|
|
||||||
func (s *Store) UpdateParticipantActivity(ctx context.Context, id uuid.UUID) error {
|
|
||||||
_, err := s.pool.Exec(ctx, `
|
|
||||||
UPDATE workshop_participants SET
|
|
||||||
last_active_at = NOW(),
|
|
||||||
is_active = true
|
|
||||||
WHERE id = $1
|
|
||||||
`, id)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// LeaveSession marks a participant as having left
|
|
||||||
func (s *Store) LeaveSession(ctx context.Context, participantID uuid.UUID) error {
|
|
||||||
now := time.Now().UTC()
|
|
||||||
_, err := s.pool.Exec(ctx, `
|
|
||||||
UPDATE workshop_participants SET
|
|
||||||
is_active = false,
|
|
||||||
left_at = $2
|
|
||||||
WHERE id = $1
|
|
||||||
`, participantID, now)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// UpdateParticipant updates a participant's information
|
|
||||||
func (s *Store) UpdateParticipant(ctx context.Context, p *Participant) error {
|
|
||||||
_, err := s.pool.Exec(ctx, `
|
|
||||||
UPDATE workshop_participants SET
|
|
||||||
name = $2,
|
|
||||||
email = $3,
|
|
||||||
role = $4,
|
|
||||||
department = $5,
|
|
||||||
can_edit = $6,
|
|
||||||
can_comment = $7,
|
|
||||||
can_approve = $8
|
|
||||||
WHERE id = $1
|
|
||||||
`,
|
|
||||||
p.ID,
|
|
||||||
p.Name, p.Email, string(p.Role), p.Department,
|
|
||||||
p.CanEdit, p.CanComment, p.CanApprove,
|
|
||||||
)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Comment Operations
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
// AddComment adds a comment to a session
|
|
||||||
func (s *Store) AddComment(ctx context.Context, c *Comment) error {
|
|
||||||
c.ID = uuid.New()
|
|
||||||
c.CreatedAt = time.Now().UTC()
|
|
||||||
c.UpdatedAt = c.CreatedAt
|
|
||||||
|
|
||||||
_, err := s.pool.Exec(ctx, `
|
|
||||||
INSERT INTO workshop_comments (
|
|
||||||
id, session_id, participant_id,
|
|
||||||
step_number, field_id, response_id,
|
|
||||||
text, is_resolved,
|
|
||||||
created_at, updated_at
|
|
||||||
) VALUES (
|
|
||||||
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10
|
|
||||||
)
|
|
||||||
`,
|
|
||||||
c.ID, c.SessionID, c.ParticipantID,
|
|
||||||
c.StepNumber, c.FieldID, c.ResponseID,
|
|
||||||
c.Text, c.IsResolved,
|
|
||||||
c.CreatedAt, c.UpdatedAt,
|
|
||||||
)
|
|
||||||
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetComments retrieves comments for a session
|
|
||||||
func (s *Store) GetComments(ctx context.Context, sessionID uuid.UUID, stepNumber *int) ([]Comment, error) {
|
|
||||||
query := `
|
|
||||||
SELECT
|
|
||||||
id, session_id, participant_id,
|
|
||||||
step_number, field_id, response_id,
|
|
||||||
text, is_resolved,
|
|
||||||
created_at, updated_at
|
|
||||||
FROM workshop_comments WHERE session_id = $1`
|
|
||||||
|
|
||||||
args := []interface{}{sessionID}
|
|
||||||
if stepNumber != nil {
|
|
||||||
query += " AND step_number = $2"
|
|
||||||
args = append(args, *stepNumber)
|
|
||||||
}
|
|
||||||
|
|
||||||
query += " ORDER BY created_at ASC"
|
|
||||||
|
|
||||||
rows, err := s.pool.Query(ctx, query, args...)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
var comments []Comment
|
|
||||||
for rows.Next() {
|
|
||||||
var c Comment
|
|
||||||
err := rows.Scan(
|
|
||||||
&c.ID, &c.SessionID, &c.ParticipantID,
|
|
||||||
&c.StepNumber, &c.FieldID, &c.ResponseID,
|
|
||||||
&c.Text, &c.IsResolved,
|
|
||||||
&c.CreatedAt, &c.UpdatedAt,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
comments = append(comments, c)
|
|
||||||
}
|
|
||||||
|
|
||||||
return comments, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Response Operations
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
// SaveResponse creates or updates a response
|
|
||||||
func (s *Store) SaveResponse(ctx context.Context, r *Response) error {
|
|
||||||
r.UpdatedAt = time.Now().UTC()
|
|
||||||
|
|
||||||
valueJSON, _ := json.Marshal(r.Value)
|
|
||||||
|
|
||||||
// Upsert based on session_id, participant_id, field_id
|
|
||||||
_, err := s.pool.Exec(ctx, `
|
|
||||||
INSERT INTO workshop_responses (
|
|
||||||
id, session_id, participant_id,
|
|
||||||
step_number, field_id,
|
|
||||||
value, value_type, status,
|
|
||||||
created_at, updated_at
|
|
||||||
) VALUES (
|
|
||||||
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10
|
|
||||||
)
|
|
||||||
ON CONFLICT (session_id, participant_id, field_id) DO UPDATE SET
|
|
||||||
value = $6,
|
|
||||||
value_type = $7,
|
|
||||||
status = $8,
|
|
||||||
updated_at = $10
|
|
||||||
`,
|
|
||||||
uuid.New(), r.SessionID, r.ParticipantID,
|
|
||||||
r.StepNumber, r.FieldID,
|
|
||||||
valueJSON, r.ValueType, string(r.Status),
|
|
||||||
r.UpdatedAt, r.UpdatedAt,
|
|
||||||
)
|
|
||||||
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetResponses retrieves responses for a session
|
|
||||||
func (s *Store) GetResponses(ctx context.Context, sessionID uuid.UUID, stepNumber *int) ([]Response, error) {
|
|
||||||
query := `
|
|
||||||
SELECT
|
|
||||||
id, session_id, participant_id,
|
|
||||||
step_number, field_id,
|
|
||||||
value, value_type, status,
|
|
||||||
reviewed_by, reviewed_at, review_notes,
|
|
||||||
created_at, updated_at
|
|
||||||
FROM workshop_responses WHERE session_id = $1`
|
|
||||||
|
|
||||||
args := []interface{}{sessionID}
|
|
||||||
if stepNumber != nil {
|
|
||||||
query += " AND step_number = $2"
|
|
||||||
args = append(args, *stepNumber)
|
|
||||||
}
|
|
||||||
|
|
||||||
query += " ORDER BY step_number ASC, field_id ASC"
|
|
||||||
|
|
||||||
rows, err := s.pool.Query(ctx, query, args...)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
var responses []Response
|
|
||||||
for rows.Next() {
|
|
||||||
var r Response
|
|
||||||
var status string
|
|
||||||
var valueJSON []byte
|
|
||||||
|
|
||||||
err := rows.Scan(
|
|
||||||
&r.ID, &r.SessionID, &r.ParticipantID,
|
|
||||||
&r.StepNumber, &r.FieldID,
|
|
||||||
&valueJSON, &r.ValueType, &status,
|
|
||||||
&r.ReviewedBy, &r.ReviewedAt, &r.ReviewNotes,
|
|
||||||
&r.CreatedAt, &r.UpdatedAt,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
r.Status = ResponseStatus(status)
|
|
||||||
json.Unmarshal(valueJSON, &r.Value)
|
|
||||||
|
|
||||||
responses = append(responses, r)
|
|
||||||
}
|
|
||||||
|
|
||||||
return responses, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Step Progress Operations
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
// UpdateStepProgress updates the progress for a step
|
|
||||||
func (s *Store) UpdateStepProgress(ctx context.Context, sessionID uuid.UUID, stepNumber int, status string, progress int) error {
|
|
||||||
now := time.Now().UTC()
|
|
||||||
|
|
||||||
_, err := s.pool.Exec(ctx, `
|
|
||||||
INSERT INTO workshop_step_progress (
|
|
||||||
id, session_id, step_number,
|
|
||||||
status, progress, started_at
|
|
||||||
) VALUES (
|
|
||||||
$1, $2, $3, $4, $5, $6
|
|
||||||
)
|
|
||||||
ON CONFLICT (session_id, step_number) DO UPDATE SET
|
|
||||||
status = $4,
|
|
||||||
progress = $5,
|
|
||||||
completed_at = CASE WHEN $4 = 'completed' THEN $6 ELSE NULL END
|
|
||||||
`, uuid.New(), sessionID, stepNumber, status, progress, now)
|
|
||||||
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetStepProgress retrieves step progress for a session
|
|
||||||
func (s *Store) GetStepProgress(ctx context.Context, sessionID uuid.UUID) ([]StepProgress, error) {
|
|
||||||
rows, err := s.pool.Query(ctx, `
|
|
||||||
SELECT
|
|
||||||
id, session_id, step_number,
|
|
||||||
status, progress,
|
|
||||||
started_at, completed_at, notes
|
|
||||||
FROM workshop_step_progress WHERE session_id = $1
|
|
||||||
ORDER BY step_number ASC
|
|
||||||
`, sessionID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
var progress []StepProgress
|
|
||||||
for rows.Next() {
|
|
||||||
var sp StepProgress
|
|
||||||
err := rows.Scan(
|
|
||||||
&sp.ID, &sp.SessionID, &sp.StepNumber,
|
|
||||||
&sp.Status, &sp.Progress,
|
|
||||||
&sp.StartedAt, &sp.CompletedAt, &sp.Notes,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
progress = append(progress, sp)
|
|
||||||
}
|
|
||||||
|
|
||||||
return progress, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Statistics
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
// GetSessionStats returns statistics for a session
|
|
||||||
func (s *Store) GetSessionStats(ctx context.Context, sessionID uuid.UUID) (*SessionStats, error) {
|
|
||||||
stats := &SessionStats{
|
|
||||||
ResponsesByStep: make(map[int]int),
|
|
||||||
ResponsesByField: make(map[string]int),
|
|
||||||
}
|
|
||||||
|
|
||||||
// Participant counts
|
|
||||||
s.pool.QueryRow(ctx,
|
|
||||||
"SELECT COUNT(*) FROM workshop_participants WHERE session_id = $1",
|
|
||||||
sessionID).Scan(&stats.ParticipantCount)
|
|
||||||
|
|
||||||
s.pool.QueryRow(ctx,
|
|
||||||
"SELECT COUNT(*) FROM workshop_participants WHERE session_id = $1 AND is_active = true",
|
|
||||||
sessionID).Scan(&stats.ActiveParticipants)
|
|
||||||
|
|
||||||
// Response count
|
|
||||||
s.pool.QueryRow(ctx,
|
|
||||||
"SELECT COUNT(*) FROM workshop_responses WHERE session_id = $1",
|
|
||||||
sessionID).Scan(&stats.ResponseCount)
|
|
||||||
|
|
||||||
// Comment count
|
|
||||||
s.pool.QueryRow(ctx,
|
|
||||||
"SELECT COUNT(*) FROM workshop_comments WHERE session_id = $1",
|
|
||||||
sessionID).Scan(&stats.CommentCount)
|
|
||||||
|
|
||||||
// Step progress
|
|
||||||
s.pool.QueryRow(ctx,
|
|
||||||
"SELECT COUNT(*) FROM workshop_step_progress WHERE session_id = $1 AND status = 'completed'",
|
|
||||||
sessionID).Scan(&stats.CompletedSteps)
|
|
||||||
|
|
||||||
s.pool.QueryRow(ctx,
|
|
||||||
"SELECT total_steps FROM workshop_sessions WHERE id = $1",
|
|
||||||
sessionID).Scan(&stats.TotalSteps)
|
|
||||||
|
|
||||||
// Responses by step
|
|
||||||
rows, _ := s.pool.Query(ctx,
|
|
||||||
"SELECT step_number, COUNT(*) FROM workshop_responses WHERE session_id = $1 GROUP BY step_number",
|
|
||||||
sessionID)
|
|
||||||
if rows != nil {
|
|
||||||
defer rows.Close()
|
|
||||||
for rows.Next() {
|
|
||||||
var step, count int
|
|
||||||
rows.Scan(&step, &count)
|
|
||||||
stats.ResponsesByStep[step] = count
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Responses by field
|
|
||||||
rows, _ = s.pool.Query(ctx,
|
|
||||||
"SELECT field_id, COUNT(*) FROM workshop_responses WHERE session_id = $1 GROUP BY field_id",
|
|
||||||
sessionID)
|
|
||||||
if rows != nil {
|
|
||||||
defer rows.Close()
|
|
||||||
for rows.Next() {
|
|
||||||
var field string
|
|
||||||
var count int
|
|
||||||
rows.Scan(&field, &count)
|
|
||||||
stats.ResponsesByField[field] = count
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Average progress
|
|
||||||
if stats.TotalSteps > 0 {
|
|
||||||
stats.AverageProgress = (stats.CompletedSteps * 100) / stats.TotalSteps
|
|
||||||
}
|
|
||||||
|
|
||||||
return stats, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetSessionSummary returns a complete session summary
|
|
||||||
func (s *Store) GetSessionSummary(ctx context.Context, sessionID uuid.UUID) (*SessionSummary, error) {
|
|
||||||
session, err := s.GetSession(ctx, sessionID)
|
|
||||||
if err != nil || session == nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
participants, err := s.ListParticipants(ctx, sessionID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
stepProgress, err := s.GetStepProgress(ctx, sessionID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
var responseCount int
|
|
||||||
s.pool.QueryRow(ctx,
|
|
||||||
"SELECT COUNT(*) FROM workshop_responses WHERE session_id = $1",
|
|
||||||
sessionID).Scan(&responseCount)
|
|
||||||
|
|
||||||
completedSteps := 0
|
|
||||||
for _, sp := range stepProgress {
|
|
||||||
if sp.Status == "completed" {
|
|
||||||
completedSteps++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
progress := 0
|
|
||||||
if session.TotalSteps > 0 {
|
|
||||||
progress = (completedSteps * 100) / session.TotalSteps
|
|
||||||
}
|
|
||||||
|
|
||||||
return &SessionSummary{
|
|
||||||
Session: session,
|
|
||||||
Participants: participants,
|
|
||||||
StepProgress: stepProgress,
|
|
||||||
TotalResponses: responseCount,
|
|
||||||
CompletedSteps: completedSteps,
|
|
||||||
OverallProgress: progress,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Helpers
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
// generateJoinCode generates a random 6-character join code
|
|
||||||
func generateJoinCode() string {
|
|
||||||
b := make([]byte, 4)
|
|
||||||
rand.Read(b)
|
|
||||||
code := base32.StdEncoding.EncodeToString(b)[:6]
|
|
||||||
return strings.ToUpper(code)
|
|
||||||
}
|
|
||||||
225
ai-compliance-sdk/internal/workshop/store_participants.go
Normal file
225
ai-compliance-sdk/internal/workshop/store_participants.go
Normal file
@@ -0,0 +1,225 @@
|
|||||||
|
package workshop
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Participant Operations
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
// AddParticipant adds a participant to a session
|
||||||
|
func (s *Store) AddParticipant(ctx context.Context, p *Participant) error {
|
||||||
|
p.ID = uuid.New()
|
||||||
|
p.JoinedAt = time.Now().UTC()
|
||||||
|
p.IsActive = true
|
||||||
|
now := p.JoinedAt
|
||||||
|
p.LastActiveAt = &now
|
||||||
|
|
||||||
|
_, err := s.pool.Exec(ctx, `
|
||||||
|
INSERT INTO workshop_participants (
|
||||||
|
id, session_id, user_id,
|
||||||
|
name, email, role, department,
|
||||||
|
is_active, last_active_at, joined_at, left_at,
|
||||||
|
can_edit, can_comment, can_approve
|
||||||
|
) VALUES (
|
||||||
|
$1, $2, $3,
|
||||||
|
$4, $5, $6, $7,
|
||||||
|
$8, $9, $10, $11,
|
||||||
|
$12, $13, $14
|
||||||
|
)
|
||||||
|
`,
|
||||||
|
p.ID, p.SessionID, p.UserID,
|
||||||
|
p.Name, p.Email, string(p.Role), p.Department,
|
||||||
|
p.IsActive, p.LastActiveAt, p.JoinedAt, p.LeftAt,
|
||||||
|
p.CanEdit, p.CanComment, p.CanApprove,
|
||||||
|
)
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetParticipant retrieves a participant by ID
|
||||||
|
func (s *Store) GetParticipant(ctx context.Context, id uuid.UUID) (*Participant, error) {
|
||||||
|
var p Participant
|
||||||
|
var role string
|
||||||
|
|
||||||
|
err := s.pool.QueryRow(ctx, `
|
||||||
|
SELECT
|
||||||
|
id, session_id, user_id,
|
||||||
|
name, email, role, department,
|
||||||
|
is_active, last_active_at, joined_at, left_at,
|
||||||
|
can_edit, can_comment, can_approve
|
||||||
|
FROM workshop_participants WHERE id = $1
|
||||||
|
`, id).Scan(
|
||||||
|
&p.ID, &p.SessionID, &p.UserID,
|
||||||
|
&p.Name, &p.Email, &role, &p.Department,
|
||||||
|
&p.IsActive, &p.LastActiveAt, &p.JoinedAt, &p.LeftAt,
|
||||||
|
&p.CanEdit, &p.CanComment, &p.CanApprove,
|
||||||
|
)
|
||||||
|
|
||||||
|
if err == pgx.ErrNoRows {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
p.Role = ParticipantRole(role)
|
||||||
|
return &p, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListParticipants lists participants for a session
|
||||||
|
func (s *Store) ListParticipants(ctx context.Context, sessionID uuid.UUID) ([]Participant, error) {
|
||||||
|
rows, err := s.pool.Query(ctx, `
|
||||||
|
SELECT
|
||||||
|
id, session_id, user_id,
|
||||||
|
name, email, role, department,
|
||||||
|
is_active, last_active_at, joined_at, left_at,
|
||||||
|
can_edit, can_comment, can_approve
|
||||||
|
FROM workshop_participants WHERE session_id = $1
|
||||||
|
ORDER BY joined_at ASC
|
||||||
|
`, sessionID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var participants []Participant
|
||||||
|
for rows.Next() {
|
||||||
|
var p Participant
|
||||||
|
var role string
|
||||||
|
|
||||||
|
err := rows.Scan(
|
||||||
|
&p.ID, &p.SessionID, &p.UserID,
|
||||||
|
&p.Name, &p.Email, &role, &p.Department,
|
||||||
|
&p.IsActive, &p.LastActiveAt, &p.JoinedAt, &p.LeftAt,
|
||||||
|
&p.CanEdit, &p.CanComment, &p.CanApprove,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
p.Role = ParticipantRole(role)
|
||||||
|
participants = append(participants, p)
|
||||||
|
}
|
||||||
|
|
||||||
|
return participants, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateParticipantActivity updates the last active timestamp
|
||||||
|
func (s *Store) UpdateParticipantActivity(ctx context.Context, id uuid.UUID) error {
|
||||||
|
_, err := s.pool.Exec(ctx, `
|
||||||
|
UPDATE workshop_participants SET
|
||||||
|
last_active_at = NOW(),
|
||||||
|
is_active = true
|
||||||
|
WHERE id = $1
|
||||||
|
`, id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// LeaveSession marks a participant as having left
|
||||||
|
func (s *Store) LeaveSession(ctx context.Context, participantID uuid.UUID) error {
|
||||||
|
now := time.Now().UTC()
|
||||||
|
_, err := s.pool.Exec(ctx, `
|
||||||
|
UPDATE workshop_participants SET
|
||||||
|
is_active = false,
|
||||||
|
left_at = $2
|
||||||
|
WHERE id = $1
|
||||||
|
`, participantID, now)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateParticipant updates a participant's information
|
||||||
|
func (s *Store) UpdateParticipant(ctx context.Context, p *Participant) error {
|
||||||
|
_, err := s.pool.Exec(ctx, `
|
||||||
|
UPDATE workshop_participants SET
|
||||||
|
name = $2,
|
||||||
|
email = $3,
|
||||||
|
role = $4,
|
||||||
|
department = $5,
|
||||||
|
can_edit = $6,
|
||||||
|
can_comment = $7,
|
||||||
|
can_approve = $8
|
||||||
|
WHERE id = $1
|
||||||
|
`,
|
||||||
|
p.ID,
|
||||||
|
p.Name, p.Email, string(p.Role), p.Department,
|
||||||
|
p.CanEdit, p.CanComment, p.CanApprove,
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Comment Operations
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
// AddComment adds a comment to a session
|
||||||
|
func (s *Store) AddComment(ctx context.Context, c *Comment) error {
|
||||||
|
c.ID = uuid.New()
|
||||||
|
c.CreatedAt = time.Now().UTC()
|
||||||
|
c.UpdatedAt = c.CreatedAt
|
||||||
|
|
||||||
|
_, err := s.pool.Exec(ctx, `
|
||||||
|
INSERT INTO workshop_comments (
|
||||||
|
id, session_id, participant_id,
|
||||||
|
step_number, field_id, response_id,
|
||||||
|
text, is_resolved,
|
||||||
|
created_at, updated_at
|
||||||
|
) VALUES (
|
||||||
|
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10
|
||||||
|
)
|
||||||
|
`,
|
||||||
|
c.ID, c.SessionID, c.ParticipantID,
|
||||||
|
c.StepNumber, c.FieldID, c.ResponseID,
|
||||||
|
c.Text, c.IsResolved,
|
||||||
|
c.CreatedAt, c.UpdatedAt,
|
||||||
|
)
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetComments retrieves comments for a session
|
||||||
|
func (s *Store) GetComments(ctx context.Context, sessionID uuid.UUID, stepNumber *int) ([]Comment, error) {
|
||||||
|
query := `
|
||||||
|
SELECT
|
||||||
|
id, session_id, participant_id,
|
||||||
|
step_number, field_id, response_id,
|
||||||
|
text, is_resolved,
|
||||||
|
created_at, updated_at
|
||||||
|
FROM workshop_comments WHERE session_id = $1`
|
||||||
|
|
||||||
|
args := []interface{}{sessionID}
|
||||||
|
if stepNumber != nil {
|
||||||
|
query += " AND step_number = $2"
|
||||||
|
args = append(args, *stepNumber)
|
||||||
|
}
|
||||||
|
|
||||||
|
query += " ORDER BY created_at ASC"
|
||||||
|
|
||||||
|
rows, err := s.pool.Query(ctx, query, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var comments []Comment
|
||||||
|
for rows.Next() {
|
||||||
|
var c Comment
|
||||||
|
err := rows.Scan(
|
||||||
|
&c.ID, &c.SessionID, &c.ParticipantID,
|
||||||
|
&c.StepNumber, &c.FieldID, &c.ResponseID,
|
||||||
|
&c.Text, &c.IsResolved,
|
||||||
|
&c.CreatedAt, &c.UpdatedAt,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
comments = append(comments, c)
|
||||||
|
}
|
||||||
|
|
||||||
|
return comments, nil
|
||||||
|
}
|
||||||
269
ai-compliance-sdk/internal/workshop/store_responses.go
Normal file
269
ai-compliance-sdk/internal/workshop/store_responses.go
Normal file
@@ -0,0 +1,269 @@
|
|||||||
|
package workshop
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Response Operations
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
// SaveResponse creates or updates a response
|
||||||
|
func (s *Store) SaveResponse(ctx context.Context, r *Response) error {
|
||||||
|
r.UpdatedAt = time.Now().UTC()
|
||||||
|
|
||||||
|
valueJSON, _ := json.Marshal(r.Value)
|
||||||
|
|
||||||
|
// Upsert based on session_id, participant_id, field_id
|
||||||
|
_, err := s.pool.Exec(ctx, `
|
||||||
|
INSERT INTO workshop_responses (
|
||||||
|
id, session_id, participant_id,
|
||||||
|
step_number, field_id,
|
||||||
|
value, value_type, status,
|
||||||
|
created_at, updated_at
|
||||||
|
) VALUES (
|
||||||
|
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10
|
||||||
|
)
|
||||||
|
ON CONFLICT (session_id, participant_id, field_id) DO UPDATE SET
|
||||||
|
value = $6,
|
||||||
|
value_type = $7,
|
||||||
|
status = $8,
|
||||||
|
updated_at = $10
|
||||||
|
`,
|
||||||
|
uuid.New(), r.SessionID, r.ParticipantID,
|
||||||
|
r.StepNumber, r.FieldID,
|
||||||
|
valueJSON, r.ValueType, string(r.Status),
|
||||||
|
r.UpdatedAt, r.UpdatedAt,
|
||||||
|
)
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetResponses retrieves responses for a session
|
||||||
|
func (s *Store) GetResponses(ctx context.Context, sessionID uuid.UUID, stepNumber *int) ([]Response, error) {
|
||||||
|
query := `
|
||||||
|
SELECT
|
||||||
|
id, session_id, participant_id,
|
||||||
|
step_number, field_id,
|
||||||
|
value, value_type, status,
|
||||||
|
reviewed_by, reviewed_at, review_notes,
|
||||||
|
created_at, updated_at
|
||||||
|
FROM workshop_responses WHERE session_id = $1`
|
||||||
|
|
||||||
|
args := []interface{}{sessionID}
|
||||||
|
if stepNumber != nil {
|
||||||
|
query += " AND step_number = $2"
|
||||||
|
args = append(args, *stepNumber)
|
||||||
|
}
|
||||||
|
|
||||||
|
query += " ORDER BY step_number ASC, field_id ASC"
|
||||||
|
|
||||||
|
rows, err := s.pool.Query(ctx, query, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var responses []Response
|
||||||
|
for rows.Next() {
|
||||||
|
var r Response
|
||||||
|
var status string
|
||||||
|
var valueJSON []byte
|
||||||
|
|
||||||
|
err := rows.Scan(
|
||||||
|
&r.ID, &r.SessionID, &r.ParticipantID,
|
||||||
|
&r.StepNumber, &r.FieldID,
|
||||||
|
&valueJSON, &r.ValueType, &status,
|
||||||
|
&r.ReviewedBy, &r.ReviewedAt, &r.ReviewNotes,
|
||||||
|
&r.CreatedAt, &r.UpdatedAt,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
r.Status = ResponseStatus(status)
|
||||||
|
json.Unmarshal(valueJSON, &r.Value)
|
||||||
|
|
||||||
|
responses = append(responses, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
return responses, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Step Progress Operations
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
// UpdateStepProgress updates the progress for a step
|
||||||
|
func (s *Store) UpdateStepProgress(ctx context.Context, sessionID uuid.UUID, stepNumber int, status string, progress int) error {
|
||||||
|
now := time.Now().UTC()
|
||||||
|
|
||||||
|
_, err := s.pool.Exec(ctx, `
|
||||||
|
INSERT INTO workshop_step_progress (
|
||||||
|
id, session_id, step_number,
|
||||||
|
status, progress, started_at
|
||||||
|
) VALUES (
|
||||||
|
$1, $2, $3, $4, $5, $6
|
||||||
|
)
|
||||||
|
ON CONFLICT (session_id, step_number) DO UPDATE SET
|
||||||
|
status = $4,
|
||||||
|
progress = $5,
|
||||||
|
completed_at = CASE WHEN $4 = 'completed' THEN $6 ELSE NULL END
|
||||||
|
`, uuid.New(), sessionID, stepNumber, status, progress, now)
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetStepProgress retrieves step progress for a session
|
||||||
|
func (s *Store) GetStepProgress(ctx context.Context, sessionID uuid.UUID) ([]StepProgress, error) {
|
||||||
|
rows, err := s.pool.Query(ctx, `
|
||||||
|
SELECT
|
||||||
|
id, session_id, step_number,
|
||||||
|
status, progress,
|
||||||
|
started_at, completed_at, notes
|
||||||
|
FROM workshop_step_progress WHERE session_id = $1
|
||||||
|
ORDER BY step_number ASC
|
||||||
|
`, sessionID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var progress []StepProgress
|
||||||
|
for rows.Next() {
|
||||||
|
var sp StepProgress
|
||||||
|
err := rows.Scan(
|
||||||
|
&sp.ID, &sp.SessionID, &sp.StepNumber,
|
||||||
|
&sp.Status, &sp.Progress,
|
||||||
|
&sp.StartedAt, &sp.CompletedAt, &sp.Notes,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
progress = append(progress, sp)
|
||||||
|
}
|
||||||
|
|
||||||
|
return progress, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Statistics
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
// GetSessionStats returns statistics for a session
|
||||||
|
func (s *Store) GetSessionStats(ctx context.Context, sessionID uuid.UUID) (*SessionStats, error) {
|
||||||
|
stats := &SessionStats{
|
||||||
|
ResponsesByStep: make(map[int]int),
|
||||||
|
ResponsesByField: make(map[string]int),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Participant counts
|
||||||
|
s.pool.QueryRow(ctx,
|
||||||
|
"SELECT COUNT(*) FROM workshop_participants WHERE session_id = $1",
|
||||||
|
sessionID).Scan(&stats.ParticipantCount)
|
||||||
|
|
||||||
|
s.pool.QueryRow(ctx,
|
||||||
|
"SELECT COUNT(*) FROM workshop_participants WHERE session_id = $1 AND is_active = true",
|
||||||
|
sessionID).Scan(&stats.ActiveParticipants)
|
||||||
|
|
||||||
|
// Response count
|
||||||
|
s.pool.QueryRow(ctx,
|
||||||
|
"SELECT COUNT(*) FROM workshop_responses WHERE session_id = $1",
|
||||||
|
sessionID).Scan(&stats.ResponseCount)
|
||||||
|
|
||||||
|
// Comment count
|
||||||
|
s.pool.QueryRow(ctx,
|
||||||
|
"SELECT COUNT(*) FROM workshop_comments WHERE session_id = $1",
|
||||||
|
sessionID).Scan(&stats.CommentCount)
|
||||||
|
|
||||||
|
// Step progress
|
||||||
|
s.pool.QueryRow(ctx,
|
||||||
|
"SELECT COUNT(*) FROM workshop_step_progress WHERE session_id = $1 AND status = 'completed'",
|
||||||
|
sessionID).Scan(&stats.CompletedSteps)
|
||||||
|
|
||||||
|
s.pool.QueryRow(ctx,
|
||||||
|
"SELECT total_steps FROM workshop_sessions WHERE id = $1",
|
||||||
|
sessionID).Scan(&stats.TotalSteps)
|
||||||
|
|
||||||
|
// Responses by step
|
||||||
|
rows, _ := s.pool.Query(ctx,
|
||||||
|
"SELECT step_number, COUNT(*) FROM workshop_responses WHERE session_id = $1 GROUP BY step_number",
|
||||||
|
sessionID)
|
||||||
|
if rows != nil {
|
||||||
|
defer rows.Close()
|
||||||
|
for rows.Next() {
|
||||||
|
var step, count int
|
||||||
|
rows.Scan(&step, &count)
|
||||||
|
stats.ResponsesByStep[step] = count
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Responses by field
|
||||||
|
rows, _ = s.pool.Query(ctx,
|
||||||
|
"SELECT field_id, COUNT(*) FROM workshop_responses WHERE session_id = $1 GROUP BY field_id",
|
||||||
|
sessionID)
|
||||||
|
if rows != nil {
|
||||||
|
defer rows.Close()
|
||||||
|
for rows.Next() {
|
||||||
|
var field string
|
||||||
|
var count int
|
||||||
|
rows.Scan(&field, &count)
|
||||||
|
stats.ResponsesByField[field] = count
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Average progress
|
||||||
|
if stats.TotalSteps > 0 {
|
||||||
|
stats.AverageProgress = (stats.CompletedSteps * 100) / stats.TotalSteps
|
||||||
|
}
|
||||||
|
|
||||||
|
return stats, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSessionSummary returns a complete session summary
|
||||||
|
func (s *Store) GetSessionSummary(ctx context.Context, sessionID uuid.UUID) (*SessionSummary, error) {
|
||||||
|
session, err := s.GetSession(ctx, sessionID)
|
||||||
|
if err != nil || session == nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
participants, err := s.ListParticipants(ctx, sessionID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
stepProgress, err := s.GetStepProgress(ctx, sessionID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var responseCount int
|
||||||
|
s.pool.QueryRow(ctx,
|
||||||
|
"SELECT COUNT(*) FROM workshop_responses WHERE session_id = $1",
|
||||||
|
sessionID).Scan(&responseCount)
|
||||||
|
|
||||||
|
completedSteps := 0
|
||||||
|
for _, sp := range stepProgress {
|
||||||
|
if sp.Status == "completed" {
|
||||||
|
completedSteps++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
progress := 0
|
||||||
|
if session.TotalSteps > 0 {
|
||||||
|
progress = (completedSteps * 100) / session.TotalSteps
|
||||||
|
}
|
||||||
|
|
||||||
|
return &SessionSummary{
|
||||||
|
Session: session,
|
||||||
|
Participants: participants,
|
||||||
|
StepProgress: stepProgress,
|
||||||
|
TotalResponses: responseCount,
|
||||||
|
CompletedSteps: completedSteps,
|
||||||
|
OverallProgress: progress,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
317
ai-compliance-sdk/internal/workshop/store_sessions.go
Normal file
317
ai-compliance-sdk/internal/workshop/store_sessions.go
Normal file
@@ -0,0 +1,317 @@
|
|||||||
|
package workshop
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/base32"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Store handles workshop session data persistence
|
||||||
|
type Store struct {
|
||||||
|
pool *pgxpool.Pool
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewStore creates a new workshop store
|
||||||
|
func NewStore(pool *pgxpool.Pool) *Store {
|
||||||
|
return &Store{pool: pool}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Session CRUD Operations
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
// CreateSession creates a new workshop session
|
||||||
|
func (s *Store) CreateSession(ctx context.Context, session *Session) error {
|
||||||
|
session.ID = uuid.New()
|
||||||
|
session.CreatedAt = time.Now().UTC()
|
||||||
|
session.UpdatedAt = session.CreatedAt
|
||||||
|
if session.Status == "" {
|
||||||
|
session.Status = SessionStatusDraft
|
||||||
|
}
|
||||||
|
if session.JoinCode == "" {
|
||||||
|
session.JoinCode = generateJoinCode()
|
||||||
|
}
|
||||||
|
|
||||||
|
settings, _ := json.Marshal(session.Settings)
|
||||||
|
|
||||||
|
_, err := s.pool.Exec(ctx, `
|
||||||
|
INSERT INTO workshop_sessions (
|
||||||
|
id, tenant_id, namespace_id,
|
||||||
|
title, description, session_type, status,
|
||||||
|
wizard_schema, current_step, total_steps,
|
||||||
|
assessment_id, roadmap_id, portfolio_id,
|
||||||
|
scheduled_start, scheduled_end, actual_start, actual_end,
|
||||||
|
join_code, require_auth, allow_anonymous,
|
||||||
|
settings,
|
||||||
|
created_at, updated_at, created_by
|
||||||
|
) VALUES (
|
||||||
|
$1, $2, $3,
|
||||||
|
$4, $5, $6, $7,
|
||||||
|
$8, $9, $10,
|
||||||
|
$11, $12, $13,
|
||||||
|
$14, $15, $16, $17,
|
||||||
|
$18, $19, $20,
|
||||||
|
$21,
|
||||||
|
$22, $23, $24
|
||||||
|
)
|
||||||
|
`,
|
||||||
|
session.ID, session.TenantID, session.NamespaceID,
|
||||||
|
session.Title, session.Description, session.SessionType, string(session.Status),
|
||||||
|
session.WizardSchema, session.CurrentStep, session.TotalSteps,
|
||||||
|
session.AssessmentID, session.RoadmapID, session.PortfolioID,
|
||||||
|
session.ScheduledStart, session.ScheduledEnd, session.ActualStart, session.ActualEnd,
|
||||||
|
session.JoinCode, session.RequireAuth, session.AllowAnonymous,
|
||||||
|
settings,
|
||||||
|
session.CreatedAt, session.UpdatedAt, session.CreatedBy,
|
||||||
|
)
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSession retrieves a session by ID
|
||||||
|
func (s *Store) GetSession(ctx context.Context, id uuid.UUID) (*Session, error) {
|
||||||
|
var session Session
|
||||||
|
var status string
|
||||||
|
var settings []byte
|
||||||
|
|
||||||
|
err := s.pool.QueryRow(ctx, `
|
||||||
|
SELECT
|
||||||
|
id, tenant_id, namespace_id,
|
||||||
|
title, description, session_type, status,
|
||||||
|
wizard_schema, current_step, total_steps,
|
||||||
|
assessment_id, roadmap_id, portfolio_id,
|
||||||
|
scheduled_start, scheduled_end, actual_start, actual_end,
|
||||||
|
join_code, require_auth, allow_anonymous,
|
||||||
|
settings,
|
||||||
|
created_at, updated_at, created_by
|
||||||
|
FROM workshop_sessions WHERE id = $1
|
||||||
|
`, id).Scan(
|
||||||
|
&session.ID, &session.TenantID, &session.NamespaceID,
|
||||||
|
&session.Title, &session.Description, &session.SessionType, &status,
|
||||||
|
&session.WizardSchema, &session.CurrentStep, &session.TotalSteps,
|
||||||
|
&session.AssessmentID, &session.RoadmapID, &session.PortfolioID,
|
||||||
|
&session.ScheduledStart, &session.ScheduledEnd, &session.ActualStart, &session.ActualEnd,
|
||||||
|
&session.JoinCode, &session.RequireAuth, &session.AllowAnonymous,
|
||||||
|
&settings,
|
||||||
|
&session.CreatedAt, &session.UpdatedAt, &session.CreatedBy,
|
||||||
|
)
|
||||||
|
|
||||||
|
if err == pgx.ErrNoRows {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
session.Status = SessionStatus(status)
|
||||||
|
json.Unmarshal(settings, &session.Settings)
|
||||||
|
|
||||||
|
return &session, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSessionByJoinCode retrieves a session by its join code
|
||||||
|
func (s *Store) GetSessionByJoinCode(ctx context.Context, code string) (*Session, error) {
|
||||||
|
var id uuid.UUID
|
||||||
|
err := s.pool.QueryRow(ctx,
|
||||||
|
"SELECT id FROM workshop_sessions WHERE join_code = $1",
|
||||||
|
strings.ToUpper(code),
|
||||||
|
).Scan(&id)
|
||||||
|
|
||||||
|
if err == pgx.ErrNoRows {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.GetSession(ctx, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListSessions lists sessions for a tenant with optional filters
|
||||||
|
func (s *Store) ListSessions(ctx context.Context, tenantID uuid.UUID, filters *SessionFilters) ([]Session, error) {
|
||||||
|
query := `
|
||||||
|
SELECT
|
||||||
|
id, tenant_id, namespace_id,
|
||||||
|
title, description, session_type, status,
|
||||||
|
wizard_schema, current_step, total_steps,
|
||||||
|
assessment_id, roadmap_id, portfolio_id,
|
||||||
|
scheduled_start, scheduled_end, actual_start, actual_end,
|
||||||
|
join_code, require_auth, allow_anonymous,
|
||||||
|
settings,
|
||||||
|
created_at, updated_at, created_by
|
||||||
|
FROM workshop_sessions WHERE tenant_id = $1`
|
||||||
|
|
||||||
|
args := []interface{}{tenantID}
|
||||||
|
argIdx := 2
|
||||||
|
|
||||||
|
if filters != nil {
|
||||||
|
if filters.Status != "" {
|
||||||
|
query += fmt.Sprintf(" AND status = $%d", argIdx)
|
||||||
|
args = append(args, string(filters.Status))
|
||||||
|
argIdx++
|
||||||
|
}
|
||||||
|
if filters.SessionType != "" {
|
||||||
|
query += fmt.Sprintf(" AND session_type = $%d", argIdx)
|
||||||
|
args = append(args, filters.SessionType)
|
||||||
|
argIdx++
|
||||||
|
}
|
||||||
|
if filters.AssessmentID != nil {
|
||||||
|
query += fmt.Sprintf(" AND assessment_id = $%d", argIdx)
|
||||||
|
args = append(args, *filters.AssessmentID)
|
||||||
|
argIdx++
|
||||||
|
}
|
||||||
|
if filters.CreatedBy != nil {
|
||||||
|
query += fmt.Sprintf(" AND created_by = $%d", argIdx)
|
||||||
|
args = append(args, *filters.CreatedBy)
|
||||||
|
argIdx++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
query += " ORDER BY created_at DESC"
|
||||||
|
|
||||||
|
if filters != nil && filters.Limit > 0 {
|
||||||
|
query += fmt.Sprintf(" LIMIT $%d", argIdx)
|
||||||
|
args = append(args, filters.Limit)
|
||||||
|
argIdx++
|
||||||
|
|
||||||
|
if filters.Offset > 0 {
|
||||||
|
query += fmt.Sprintf(" OFFSET $%d", argIdx)
|
||||||
|
args = append(args, filters.Offset)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := s.pool.Query(ctx, query, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var sessions []Session
|
||||||
|
for rows.Next() {
|
||||||
|
var session Session
|
||||||
|
var status string
|
||||||
|
var settings []byte
|
||||||
|
|
||||||
|
err := rows.Scan(
|
||||||
|
&session.ID, &session.TenantID, &session.NamespaceID,
|
||||||
|
&session.Title, &session.Description, &session.SessionType, &status,
|
||||||
|
&session.WizardSchema, &session.CurrentStep, &session.TotalSteps,
|
||||||
|
&session.AssessmentID, &session.RoadmapID, &session.PortfolioID,
|
||||||
|
&session.ScheduledStart, &session.ScheduledEnd, &session.ActualStart, &session.ActualEnd,
|
||||||
|
&session.JoinCode, &session.RequireAuth, &session.AllowAnonymous,
|
||||||
|
&settings,
|
||||||
|
&session.CreatedAt, &session.UpdatedAt, &session.CreatedBy,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
session.Status = SessionStatus(status)
|
||||||
|
json.Unmarshal(settings, &session.Settings)
|
||||||
|
|
||||||
|
sessions = append(sessions, session)
|
||||||
|
}
|
||||||
|
|
||||||
|
return sessions, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateSession updates a session
|
||||||
|
func (s *Store) UpdateSession(ctx context.Context, session *Session) error {
|
||||||
|
session.UpdatedAt = time.Now().UTC()
|
||||||
|
|
||||||
|
settings, _ := json.Marshal(session.Settings)
|
||||||
|
|
||||||
|
_, err := s.pool.Exec(ctx, `
|
||||||
|
UPDATE workshop_sessions SET
|
||||||
|
title = $2, description = $3, status = $4,
|
||||||
|
wizard_schema = $5, current_step = $6, total_steps = $7,
|
||||||
|
scheduled_start = $8, scheduled_end = $9,
|
||||||
|
actual_start = $10, actual_end = $11,
|
||||||
|
require_auth = $12, allow_anonymous = $13,
|
||||||
|
settings = $14,
|
||||||
|
updated_at = $15
|
||||||
|
WHERE id = $1
|
||||||
|
`,
|
||||||
|
session.ID, session.Title, session.Description, string(session.Status),
|
||||||
|
session.WizardSchema, session.CurrentStep, session.TotalSteps,
|
||||||
|
session.ScheduledStart, session.ScheduledEnd,
|
||||||
|
session.ActualStart, session.ActualEnd,
|
||||||
|
session.RequireAuth, session.AllowAnonymous,
|
||||||
|
settings,
|
||||||
|
session.UpdatedAt,
|
||||||
|
)
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateSessionStatus updates only the session status
|
||||||
|
func (s *Store) UpdateSessionStatus(ctx context.Context, id uuid.UUID, status SessionStatus) error {
|
||||||
|
now := time.Now().UTC()
|
||||||
|
|
||||||
|
query := "UPDATE workshop_sessions SET status = $2, updated_at = $3"
|
||||||
|
|
||||||
|
if status == SessionStatusActive {
|
||||||
|
query += ", actual_start = COALESCE(actual_start, $3)"
|
||||||
|
} else if status == SessionStatusCompleted || status == SessionStatusCancelled {
|
||||||
|
query += ", actual_end = $3"
|
||||||
|
}
|
||||||
|
|
||||||
|
query += " WHERE id = $1"
|
||||||
|
|
||||||
|
_, err := s.pool.Exec(ctx, query, id, string(status), now)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdvanceStep advances the session to the next step
|
||||||
|
func (s *Store) AdvanceStep(ctx context.Context, id uuid.UUID) error {
|
||||||
|
_, err := s.pool.Exec(ctx, `
|
||||||
|
UPDATE workshop_sessions SET
|
||||||
|
current_step = current_step + 1,
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE id = $1 AND current_step < total_steps
|
||||||
|
`, id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteSession deletes a session and its related data
|
||||||
|
func (s *Store) DeleteSession(ctx context.Context, id uuid.UUID) error {
|
||||||
|
// Delete in order: comments, responses, step_progress, participants, session
|
||||||
|
_, err := s.pool.Exec(ctx, "DELETE FROM workshop_comments WHERE session_id = $1", id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = s.pool.Exec(ctx, "DELETE FROM workshop_responses WHERE session_id = $1", id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = s.pool.Exec(ctx, "DELETE FROM workshop_step_progress WHERE session_id = $1", id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = s.pool.Exec(ctx, "DELETE FROM workshop_participants WHERE session_id = $1", id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = s.pool.Exec(ctx, "DELETE FROM workshop_sessions WHERE id = $1", id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Helpers
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
// generateJoinCode generates a random 6-character join code
|
||||||
|
func generateJoinCode() string {
|
||||||
|
b := make([]byte, 4)
|
||||||
|
rand.Read(b)
|
||||||
|
code := base32.StdEncoding.EncodeToString(b)[:6]
|
||||||
|
return strings.ToUpper(code)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user