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