Files
breakpilot-compliance/ai-compliance-sdk/internal/workshop/store.go
Benjamin Boenisch 4435e7ea0a Initial commit: breakpilot-compliance - Compliance SDK Platform
Services: Admin-Compliance, Backend-Compliance,
AI-Compliance-SDK, Consent-SDK, Developer-Portal,
PCA-Platform, DSMS

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 23:47:28 +01:00

794 lines
21 KiB
Go

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