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>
This commit is contained in:
290
ai-compliance-sdk/internal/workshop/models.go
Normal file
290
ai-compliance-sdk/internal/workshop/models.go
Normal file
@@ -0,0 +1,290 @@
|
||||
package workshop
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// ============================================================================
|
||||
// Constants / Enums
|
||||
// ============================================================================
|
||||
|
||||
// SessionStatus represents the status of a workshop session
|
||||
type SessionStatus string
|
||||
|
||||
const (
|
||||
SessionStatusDraft SessionStatus = "DRAFT"
|
||||
SessionStatusScheduled SessionStatus = "SCHEDULED"
|
||||
SessionStatusActive SessionStatus = "ACTIVE"
|
||||
SessionStatusPaused SessionStatus = "PAUSED"
|
||||
SessionStatusCompleted SessionStatus = "COMPLETED"
|
||||
SessionStatusCancelled SessionStatus = "CANCELLED"
|
||||
)
|
||||
|
||||
// ParticipantRole represents the role of a participant
|
||||
type ParticipantRole string
|
||||
|
||||
const (
|
||||
ParticipantRoleFacilitator ParticipantRole = "FACILITATOR"
|
||||
ParticipantRoleExpert ParticipantRole = "EXPERT" // DSB, IT-Security, etc.
|
||||
ParticipantRoleStakeholder ParticipantRole = "STAKEHOLDER" // Business owner
|
||||
ParticipantRoleObserver ParticipantRole = "OBSERVER"
|
||||
)
|
||||
|
||||
// ResponseStatus represents the status of a question response
|
||||
type ResponseStatus string
|
||||
|
||||
const (
|
||||
ResponseStatusPending ResponseStatus = "PENDING"
|
||||
ResponseStatusDraft ResponseStatus = "DRAFT"
|
||||
ResponseStatusSubmitted ResponseStatus = "SUBMITTED"
|
||||
ResponseStatusReviewed ResponseStatus = "REVIEWED"
|
||||
)
|
||||
|
||||
// ============================================================================
|
||||
// Main Entities
|
||||
// ============================================================================
|
||||
|
||||
// Session represents a workshop session
|
||||
type Session struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
TenantID uuid.UUID `json:"tenant_id"`
|
||||
NamespaceID *uuid.UUID `json:"namespace_id,omitempty"`
|
||||
|
||||
// Session info
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description,omitempty"`
|
||||
SessionType string `json:"session_type"` // "ucca", "dsfa", "custom"
|
||||
Status SessionStatus `json:"status"`
|
||||
|
||||
// Wizard configuration
|
||||
WizardSchema string `json:"wizard_schema,omitempty"` // Reference to wizard schema version
|
||||
CurrentStep int `json:"current_step"`
|
||||
TotalSteps int `json:"total_steps"`
|
||||
|
||||
// Linked entities
|
||||
AssessmentID *uuid.UUID `json:"assessment_id,omitempty"` // Link to UCCA assessment
|
||||
RoadmapID *uuid.UUID `json:"roadmap_id,omitempty"` // Link to roadmap
|
||||
PortfolioID *uuid.UUID `json:"portfolio_id,omitempty"` // Link to portfolio
|
||||
|
||||
// Scheduling
|
||||
ScheduledStart *time.Time `json:"scheduled_start,omitempty"`
|
||||
ScheduledEnd *time.Time `json:"scheduled_end,omitempty"`
|
||||
ActualStart *time.Time `json:"actual_start,omitempty"`
|
||||
ActualEnd *time.Time `json:"actual_end,omitempty"`
|
||||
|
||||
// Access control
|
||||
JoinCode string `json:"join_code,omitempty"` // Code for participants to join
|
||||
RequireAuth bool `json:"require_auth"` // Require authentication to join
|
||||
AllowAnonymous bool `json:"allow_anonymous"` // Allow anonymous participation
|
||||
|
||||
// Settings
|
||||
Settings SessionSettings `json:"settings"`
|
||||
|
||||
// Audit
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
CreatedBy uuid.UUID `json:"created_by"`
|
||||
}
|
||||
|
||||
// SessionSettings contains session configuration
|
||||
type SessionSettings struct {
|
||||
AllowBackNavigation bool `json:"allow_back_navigation"` // Can go back to previous steps
|
||||
RequireAllResponses bool `json:"require_all_responses"` // All questions must be answered
|
||||
ShowProgressToAll bool `json:"show_progress_to_all"` // Show progress to all participants
|
||||
AllowNotes bool `json:"allow_notes"` // Allow adding notes
|
||||
AutoSave bool `json:"auto_save"` // Auto-save responses
|
||||
TimeoutMinutes int `json:"timeout_minutes,omitempty"` // Auto-pause after inactivity
|
||||
}
|
||||
|
||||
// Participant represents a participant in a session
|
||||
type Participant struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
SessionID uuid.UUID `json:"session_id"`
|
||||
UserID *uuid.UUID `json:"user_id,omitempty"` // Nil for anonymous
|
||||
|
||||
// Info
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email,omitempty"`
|
||||
Role ParticipantRole `json:"role"`
|
||||
Department string `json:"department,omitempty"`
|
||||
|
||||
// Status
|
||||
IsActive bool `json:"is_active"`
|
||||
LastActiveAt *time.Time `json:"last_active_at,omitempty"`
|
||||
JoinedAt time.Time `json:"joined_at"`
|
||||
LeftAt *time.Time `json:"left_at,omitempty"`
|
||||
|
||||
// Permissions
|
||||
CanEdit bool `json:"can_edit"` // Can modify responses
|
||||
CanComment bool `json:"can_comment"` // Can add comments
|
||||
CanApprove bool `json:"can_approve"` // Can approve responses
|
||||
}
|
||||
|
||||
// StepProgress tracks progress on a specific wizard step
|
||||
type StepProgress struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
SessionID uuid.UUID `json:"session_id"`
|
||||
StepNumber int `json:"step_number"`
|
||||
|
||||
// Status
|
||||
Status string `json:"status"` // "pending", "in_progress", "completed", "skipped"
|
||||
Progress int `json:"progress"` // 0-100
|
||||
|
||||
// Timestamps
|
||||
StartedAt *time.Time `json:"started_at,omitempty"`
|
||||
CompletedAt *time.Time `json:"completed_at,omitempty"`
|
||||
|
||||
// Facilitator notes
|
||||
Notes string `json:"notes,omitempty"`
|
||||
}
|
||||
|
||||
// Response represents a response to a wizard question
|
||||
type Response struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
SessionID uuid.UUID `json:"session_id"`
|
||||
ParticipantID uuid.UUID `json:"participant_id"`
|
||||
|
||||
// Question reference
|
||||
StepNumber int `json:"step_number"`
|
||||
FieldID string `json:"field_id"` // From wizard schema
|
||||
|
||||
// Response data
|
||||
Value interface{} `json:"value"` // Can be string, bool, array, etc.
|
||||
ValueType string `json:"value_type"` // "string", "boolean", "array", "number"
|
||||
|
||||
// Status
|
||||
Status ResponseStatus `json:"status"`
|
||||
|
||||
// Review
|
||||
ReviewedBy *uuid.UUID `json:"reviewed_by,omitempty"`
|
||||
ReviewedAt *time.Time `json:"reviewed_at,omitempty"`
|
||||
ReviewNotes string `json:"review_notes,omitempty"`
|
||||
|
||||
// Audit
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// Comment represents a comment on a response or step
|
||||
type Comment struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
SessionID uuid.UUID `json:"session_id"`
|
||||
ParticipantID uuid.UUID `json:"participant_id"`
|
||||
|
||||
// Target
|
||||
StepNumber *int `json:"step_number,omitempty"`
|
||||
FieldID *string `json:"field_id,omitempty"`
|
||||
ResponseID *uuid.UUID `json:"response_id,omitempty"`
|
||||
|
||||
// Content
|
||||
Text string `json:"text"`
|
||||
IsResolved bool `json:"is_resolved"`
|
||||
|
||||
// Audit
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// SessionSummary contains aggregated session information
|
||||
type SessionSummary struct {
|
||||
Session *Session `json:"session"`
|
||||
Participants []Participant `json:"participants"`
|
||||
StepProgress []StepProgress `json:"step_progress"`
|
||||
TotalResponses int `json:"total_responses"`
|
||||
CompletedSteps int `json:"completed_steps"`
|
||||
OverallProgress int `json:"overall_progress"` // 0-100
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// API Request/Response Types
|
||||
// ============================================================================
|
||||
|
||||
// CreateSessionRequest is the API request for creating a session
|
||||
type CreateSessionRequest struct {
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description,omitempty"`
|
||||
SessionType string `json:"session_type"`
|
||||
WizardSchema string `json:"wizard_schema,omitempty"`
|
||||
ScheduledStart *time.Time `json:"scheduled_start,omitempty"`
|
||||
ScheduledEnd *time.Time `json:"scheduled_end,omitempty"`
|
||||
Settings SessionSettings `json:"settings,omitempty"`
|
||||
AssessmentID *uuid.UUID `json:"assessment_id,omitempty"`
|
||||
RoadmapID *uuid.UUID `json:"roadmap_id,omitempty"`
|
||||
PortfolioID *uuid.UUID `json:"portfolio_id,omitempty"`
|
||||
}
|
||||
|
||||
// CreateSessionResponse is the API response for creating a session
|
||||
type CreateSessionResponse struct {
|
||||
Session Session `json:"session"`
|
||||
JoinURL string `json:"join_url,omitempty"`
|
||||
JoinCode string `json:"join_code,omitempty"`
|
||||
}
|
||||
|
||||
// JoinSessionRequest is the API request for joining a session
|
||||
type JoinSessionRequest struct {
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email,omitempty"`
|
||||
Role ParticipantRole `json:"role,omitempty"`
|
||||
Department string `json:"department,omitempty"`
|
||||
}
|
||||
|
||||
// JoinSessionResponse is the API response for joining a session
|
||||
type JoinSessionResponse struct {
|
||||
Participant Participant `json:"participant"`
|
||||
Session Session `json:"session"`
|
||||
Token string `json:"token,omitempty"` // Session-specific token
|
||||
}
|
||||
|
||||
// SubmitResponseRequest is the API request for submitting a response
|
||||
type SubmitResponseRequest struct {
|
||||
StepNumber int `json:"step_number"`
|
||||
FieldID string `json:"field_id"`
|
||||
Value interface{} `json:"value"`
|
||||
}
|
||||
|
||||
// AdvanceStepRequest is the API request for advancing to next step
|
||||
type AdvanceStepRequest struct {
|
||||
Responses []SubmitResponseRequest `json:"responses,omitempty"` // Optional batch submit
|
||||
Notes string `json:"notes,omitempty"`
|
||||
}
|
||||
|
||||
// SessionFilters defines filters for listing sessions
|
||||
type SessionFilters struct {
|
||||
Status SessionStatus
|
||||
SessionType string
|
||||
AssessmentID *uuid.UUID
|
||||
CreatedBy *uuid.UUID
|
||||
Limit int
|
||||
Offset int
|
||||
}
|
||||
|
||||
// SessionStats contains statistics for a session
|
||||
type SessionStats struct {
|
||||
ParticipantCount int `json:"participant_count"`
|
||||
ActiveParticipants int `json:"active_participants"`
|
||||
ResponseCount int `json:"response_count"`
|
||||
CommentCount int `json:"comment_count"`
|
||||
CompletedSteps int `json:"completed_steps"`
|
||||
TotalSteps int `json:"total_steps"`
|
||||
AverageProgress int `json:"average_progress"`
|
||||
ResponsesByStep map[int]int `json:"responses_by_step"`
|
||||
ResponsesByField map[string]int `json:"responses_by_field"`
|
||||
}
|
||||
|
||||
// ExportFormat specifies the export format for session data
|
||||
type ExportFormat string
|
||||
|
||||
const (
|
||||
ExportFormatJSON ExportFormat = "json"
|
||||
ExportFormatMarkdown ExportFormat = "md"
|
||||
ExportFormatPDF ExportFormat = "pdf"
|
||||
)
|
||||
|
||||
// ExportSessionRequest is the API request for exporting session data
|
||||
type ExportSessionRequest struct {
|
||||
Format ExportFormat `json:"format"`
|
||||
IncludeComments bool `json:"include_comments"`
|
||||
IncludeHistory bool `json:"include_history"`
|
||||
}
|
||||
793
ai-compliance-sdk/internal/workshop/store.go
Normal file
793
ai-compliance-sdk/internal/workshop/store.go
Normal file
@@ -0,0 +1,793 @@
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user