package training import ( "context" "encoding/json" "fmt" "time" "github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" ) // Store handles training data persistence type Store struct { pool *pgxpool.Pool } // NewStore creates a new training store func NewStore(pool *pgxpool.Pool) *Store { return &Store{pool: pool} } // ============================================================================ // Module CRUD Operations // ============================================================================ // CreateModule creates a new training module func (s *Store) CreateModule(ctx context.Context, module *TrainingModule) error { module.ID = uuid.New() module.CreatedAt = time.Now().UTC() module.UpdatedAt = module.CreatedAt if !module.IsActive { module.IsActive = true } isoControls, _ := json.Marshal(module.ISOControls) _, err := s.pool.Exec(ctx, ` INSERT INTO training_modules ( id, tenant_id, academy_course_id, module_code, title, description, regulation_area, nis2_relevant, iso_controls, frequency_type, validity_days, risk_weight, content_type, duration_minutes, pass_threshold, is_active, 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 ) `, module.ID, module.TenantID, module.AcademyCourseID, module.ModuleCode, module.Title, module.Description, string(module.RegulationArea), module.NIS2Relevant, isoControls, string(module.FrequencyType), module.ValidityDays, module.RiskWeight, module.ContentType, module.DurationMinutes, module.PassThreshold, module.IsActive, module.SortOrder, module.CreatedAt, module.UpdatedAt, ) return err } // GetModule retrieves a module by ID func (s *Store) GetModule(ctx context.Context, id uuid.UUID) (*TrainingModule, error) { var module TrainingModule var regulationArea, frequencyType string var isoControls []byte err := s.pool.QueryRow(ctx, ` SELECT id, tenant_id, academy_course_id, module_code, title, description, regulation_area, nis2_relevant, iso_controls, frequency_type, validity_days, risk_weight, content_type, duration_minutes, pass_threshold, is_active, sort_order, created_at, updated_at FROM training_modules WHERE id = $1 `, id).Scan( &module.ID, &module.TenantID, &module.AcademyCourseID, &module.ModuleCode, &module.Title, &module.Description, ®ulationArea, &module.NIS2Relevant, &isoControls, &frequencyType, &module.ValidityDays, &module.RiskWeight, &module.ContentType, &module.DurationMinutes, &module.PassThreshold, &module.IsActive, &module.SortOrder, &module.CreatedAt, &module.UpdatedAt, ) if err == pgx.ErrNoRows { return nil, nil } if err != nil { return nil, err } module.RegulationArea = RegulationArea(regulationArea) module.FrequencyType = FrequencyType(frequencyType) json.Unmarshal(isoControls, &module.ISOControls) if module.ISOControls == nil { module.ISOControls = []string{} } return &module, nil } // ListModules lists training modules for a tenant with optional filters func (s *Store) ListModules(ctx context.Context, tenantID uuid.UUID, filters *ModuleFilters) ([]TrainingModule, int, error) { countQuery := "SELECT COUNT(*) FROM training_modules WHERE tenant_id = $1" countArgs := []interface{}{tenantID} countArgIdx := 2 query := ` SELECT id, tenant_id, academy_course_id, module_code, title, description, regulation_area, nis2_relevant, iso_controls, frequency_type, validity_days, risk_weight, content_type, duration_minutes, pass_threshold, is_active, sort_order, created_at, updated_at FROM training_modules WHERE tenant_id = $1` args := []interface{}{tenantID} argIdx := 2 if filters != nil { if filters.RegulationArea != "" { query += fmt.Sprintf(" AND regulation_area = $%d", argIdx) args = append(args, string(filters.RegulationArea)) argIdx++ countQuery += fmt.Sprintf(" AND regulation_area = $%d", countArgIdx) countArgs = append(countArgs, string(filters.RegulationArea)) countArgIdx++ } if filters.FrequencyType != "" { query += fmt.Sprintf(" AND frequency_type = $%d", argIdx) args = append(args, string(filters.FrequencyType)) argIdx++ countQuery += fmt.Sprintf(" AND frequency_type = $%d", countArgIdx) countArgs = append(countArgs, string(filters.FrequencyType)) countArgIdx++ } if filters.IsActive != nil { query += fmt.Sprintf(" AND is_active = $%d", argIdx) args = append(args, *filters.IsActive) argIdx++ countQuery += fmt.Sprintf(" AND is_active = $%d", countArgIdx) countArgs = append(countArgs, *filters.IsActive) countArgIdx++ } if filters.NIS2Relevant != nil { query += fmt.Sprintf(" AND nis2_relevant = $%d", argIdx) args = append(args, *filters.NIS2Relevant) argIdx++ countQuery += fmt.Sprintf(" AND nis2_relevant = $%d", countArgIdx) countArgs = append(countArgs, *filters.NIS2Relevant) countArgIdx++ } if filters.Search != "" { query += fmt.Sprintf(" AND (title ILIKE $%d OR description ILIKE $%d OR module_code ILIKE $%d)", argIdx, argIdx, argIdx) args = append(args, "%"+filters.Search+"%") argIdx++ countQuery += fmt.Sprintf(" AND (title ILIKE $%d OR description ILIKE $%d OR module_code ILIKE $%d)", countArgIdx, countArgIdx, countArgIdx) countArgs = append(countArgs, "%"+filters.Search+"%") countArgIdx++ } } var total int err := s.pool.QueryRow(ctx, countQuery, countArgs...).Scan(&total) if err != nil { return nil, 0, err } query += " ORDER BY sort_order ASC, 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) argIdx++ } } rows, err := s.pool.Query(ctx, query, args...) if err != nil { return nil, 0, err } defer rows.Close() var modules []TrainingModule for rows.Next() { var module TrainingModule var regulationArea, frequencyType string var isoControls []byte err := rows.Scan( &module.ID, &module.TenantID, &module.AcademyCourseID, &module.ModuleCode, &module.Title, &module.Description, ®ulationArea, &module.NIS2Relevant, &isoControls, &frequencyType, &module.ValidityDays, &module.RiskWeight, &module.ContentType, &module.DurationMinutes, &module.PassThreshold, &module.IsActive, &module.SortOrder, &module.CreatedAt, &module.UpdatedAt, ) if err != nil { return nil, 0, err } module.RegulationArea = RegulationArea(regulationArea) module.FrequencyType = FrequencyType(frequencyType) json.Unmarshal(isoControls, &module.ISOControls) if module.ISOControls == nil { module.ISOControls = []string{} } modules = append(modules, module) } if modules == nil { modules = []TrainingModule{} } return modules, total, nil } // UpdateModule updates a training module func (s *Store) UpdateModule(ctx context.Context, module *TrainingModule) error { module.UpdatedAt = time.Now().UTC() isoControls, _ := json.Marshal(module.ISOControls) _, err := s.pool.Exec(ctx, ` UPDATE training_modules SET title = $2, description = $3, nis2_relevant = $4, iso_controls = $5, validity_days = $6, risk_weight = $7, duration_minutes = $8, pass_threshold = $9, is_active = $10, sort_order = $11, updated_at = $12 WHERE id = $1 `, module.ID, module.Title, module.Description, module.NIS2Relevant, isoControls, module.ValidityDays, module.RiskWeight, module.DurationMinutes, module.PassThreshold, module.IsActive, module.SortOrder, module.UpdatedAt, ) return err } // SetAcademyCourseID links a training module to an academy course func (s *Store) SetAcademyCourseID(ctx context.Context, moduleID, courseID uuid.UUID) error { _, err := s.pool.Exec(ctx, ` UPDATE training_modules SET academy_course_id = $2, updated_at = $3 WHERE id = $1 `, moduleID, courseID, time.Now().UTC()) return err } // ============================================================================ // Matrix Operations // ============================================================================ // GetMatrixForRole returns all matrix entries for a given role func (s *Store) GetMatrixForRole(ctx context.Context, tenantID uuid.UUID, roleCode string) ([]TrainingMatrixEntry, error) { rows, err := s.pool.Query(ctx, ` SELECT tm.id, tm.tenant_id, tm.role_code, tm.module_id, tm.is_mandatory, tm.priority, tm.created_at, m.module_code, m.title FROM training_matrix tm JOIN training_modules m ON m.id = tm.module_id WHERE tm.tenant_id = $1 AND tm.role_code = $2 ORDER BY tm.priority ASC `, tenantID, roleCode) if err != nil { return nil, err } defer rows.Close() var entries []TrainingMatrixEntry for rows.Next() { var entry TrainingMatrixEntry err := rows.Scan( &entry.ID, &entry.TenantID, &entry.RoleCode, &entry.ModuleID, &entry.IsMandatory, &entry.Priority, &entry.CreatedAt, &entry.ModuleCode, &entry.ModuleTitle, ) if err != nil { return nil, err } entries = append(entries, entry) } if entries == nil { entries = []TrainingMatrixEntry{} } return entries, nil } // GetMatrixForTenant returns the full CTM for a tenant func (s *Store) GetMatrixForTenant(ctx context.Context, tenantID uuid.UUID) ([]TrainingMatrixEntry, error) { rows, err := s.pool.Query(ctx, ` SELECT tm.id, tm.tenant_id, tm.role_code, tm.module_id, tm.is_mandatory, tm.priority, tm.created_at, m.module_code, m.title FROM training_matrix tm JOIN training_modules m ON m.id = tm.module_id WHERE tm.tenant_id = $1 ORDER BY tm.role_code ASC, tm.priority ASC `, tenantID) if err != nil { return nil, err } defer rows.Close() var entries []TrainingMatrixEntry for rows.Next() { var entry TrainingMatrixEntry err := rows.Scan( &entry.ID, &entry.TenantID, &entry.RoleCode, &entry.ModuleID, &entry.IsMandatory, &entry.Priority, &entry.CreatedAt, &entry.ModuleCode, &entry.ModuleTitle, ) if err != nil { return nil, err } entries = append(entries, entry) } if entries == nil { entries = []TrainingMatrixEntry{} } return entries, nil } // SetMatrixEntry creates or updates a CTM entry func (s *Store) SetMatrixEntry(ctx context.Context, entry *TrainingMatrixEntry) error { entry.ID = uuid.New() entry.CreatedAt = time.Now().UTC() _, err := s.pool.Exec(ctx, ` INSERT INTO training_matrix ( id, tenant_id, role_code, module_id, is_mandatory, priority, created_at ) VALUES ($1, $2, $3, $4, $5, $6, $7) ON CONFLICT (tenant_id, role_code, module_id) DO UPDATE SET is_mandatory = EXCLUDED.is_mandatory, priority = EXCLUDED.priority `, entry.ID, entry.TenantID, entry.RoleCode, entry.ModuleID, entry.IsMandatory, entry.Priority, entry.CreatedAt, ) return err } // DeleteMatrixEntry removes a CTM entry func (s *Store) DeleteMatrixEntry(ctx context.Context, tenantID uuid.UUID, roleCode string, moduleID uuid.UUID) error { _, err := s.pool.Exec(ctx, "DELETE FROM training_matrix WHERE tenant_id = $1 AND role_code = $2 AND module_id = $3", tenantID, roleCode, moduleID, ) return err } // ============================================================================ // Assignment Operations // ============================================================================ // CreateAssignment creates a new training assignment func (s *Store) CreateAssignment(ctx context.Context, assignment *TrainingAssignment) error { assignment.ID = uuid.New() assignment.CreatedAt = time.Now().UTC() assignment.UpdatedAt = assignment.CreatedAt if assignment.Status == "" { assignment.Status = AssignmentStatusPending } _, err := s.pool.Exec(ctx, ` INSERT INTO training_assignments ( id, tenant_id, module_id, user_id, user_name, user_email, role_code, trigger_type, trigger_event, status, progress_percent, quiz_score, quiz_passed, quiz_attempts, started_at, completed_at, deadline, certificate_id, escalation_level, last_escalation_at, enrollment_id, 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 ) `, assignment.ID, assignment.TenantID, assignment.ModuleID, assignment.UserID, assignment.UserName, assignment.UserEmail, assignment.RoleCode, string(assignment.TriggerType), assignment.TriggerEvent, string(assignment.Status), assignment.ProgressPercent, assignment.QuizScore, assignment.QuizPassed, assignment.QuizAttempts, assignment.StartedAt, assignment.CompletedAt, assignment.Deadline, assignment.CertificateID, assignment.EscalationLevel, assignment.LastEscalationAt, assignment.EnrollmentID, assignment.CreatedAt, assignment.UpdatedAt, ) return err } // GetAssignment retrieves an assignment by ID func (s *Store) GetAssignment(ctx context.Context, id uuid.UUID) (*TrainingAssignment, error) { var a TrainingAssignment var status, triggerType string err := s.pool.QueryRow(ctx, ` SELECT ta.id, ta.tenant_id, ta.module_id, ta.user_id, ta.user_name, ta.user_email, ta.role_code, ta.trigger_type, ta.trigger_event, ta.status, ta.progress_percent, ta.quiz_score, ta.quiz_passed, ta.quiz_attempts, ta.started_at, ta.completed_at, ta.deadline, ta.certificate_id, ta.escalation_level, ta.last_escalation_at, ta.enrollment_id, ta.created_at, ta.updated_at, m.module_code, m.title FROM training_assignments ta JOIN training_modules m ON m.id = ta.module_id WHERE ta.id = $1 `, id).Scan( &a.ID, &a.TenantID, &a.ModuleID, &a.UserID, &a.UserName, &a.UserEmail, &a.RoleCode, &triggerType, &a.TriggerEvent, &status, &a.ProgressPercent, &a.QuizScore, &a.QuizPassed, &a.QuizAttempts, &a.StartedAt, &a.CompletedAt, &a.Deadline, &a.CertificateID, &a.EscalationLevel, &a.LastEscalationAt, &a.EnrollmentID, &a.CreatedAt, &a.UpdatedAt, &a.ModuleCode, &a.ModuleTitle, ) if err == pgx.ErrNoRows { return nil, nil } if err != nil { return nil, err } a.Status = AssignmentStatus(status) a.TriggerType = TriggerType(triggerType) return &a, nil } // ListAssignments lists assignments for a tenant with optional filters func (s *Store) ListAssignments(ctx context.Context, tenantID uuid.UUID, filters *AssignmentFilters) ([]TrainingAssignment, int, error) { countQuery := "SELECT COUNT(*) FROM training_assignments WHERE tenant_id = $1" countArgs := []interface{}{tenantID} countArgIdx := 2 query := ` SELECT ta.id, ta.tenant_id, ta.module_id, ta.user_id, ta.user_name, ta.user_email, ta.role_code, ta.trigger_type, ta.trigger_event, ta.status, ta.progress_percent, ta.quiz_score, ta.quiz_passed, ta.quiz_attempts, ta.started_at, ta.completed_at, ta.deadline, ta.certificate_id, ta.escalation_level, ta.last_escalation_at, ta.enrollment_id, ta.created_at, ta.updated_at, m.module_code, m.title FROM training_assignments ta JOIN training_modules m ON m.id = ta.module_id WHERE ta.tenant_id = $1` args := []interface{}{tenantID} argIdx := 2 if filters != nil { if filters.ModuleID != nil { query += fmt.Sprintf(" AND ta.module_id = $%d", argIdx) args = append(args, *filters.ModuleID) argIdx++ countQuery += fmt.Sprintf(" AND module_id = $%d", countArgIdx) countArgs = append(countArgs, *filters.ModuleID) countArgIdx++ } if filters.UserID != nil { query += fmt.Sprintf(" AND ta.user_id = $%d", argIdx) args = append(args, *filters.UserID) argIdx++ countQuery += fmt.Sprintf(" AND user_id = $%d", countArgIdx) countArgs = append(countArgs, *filters.UserID) countArgIdx++ } if filters.RoleCode != "" { query += fmt.Sprintf(" AND ta.role_code = $%d", argIdx) args = append(args, filters.RoleCode) argIdx++ countQuery += fmt.Sprintf(" AND role_code = $%d", countArgIdx) countArgs = append(countArgs, filters.RoleCode) countArgIdx++ } if filters.Status != "" { query += fmt.Sprintf(" AND ta.status = $%d", argIdx) args = append(args, string(filters.Status)) argIdx++ countQuery += fmt.Sprintf(" AND status = $%d", countArgIdx) countArgs = append(countArgs, string(filters.Status)) countArgIdx++ } if filters.Overdue != nil && *filters.Overdue { query += " AND ta.deadline < NOW() AND ta.status IN ('pending', 'in_progress')" countQuery += " AND deadline < NOW() AND status IN ('pending', 'in_progress')" } } var total int err := s.pool.QueryRow(ctx, countQuery, countArgs...).Scan(&total) if err != nil { return nil, 0, err } query += " ORDER BY ta.deadline 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) argIdx++ } } rows, err := s.pool.Query(ctx, query, args...) if err != nil { return nil, 0, err } defer rows.Close() var assignments []TrainingAssignment for rows.Next() { var a TrainingAssignment var status, triggerType string err := rows.Scan( &a.ID, &a.TenantID, &a.ModuleID, &a.UserID, &a.UserName, &a.UserEmail, &a.RoleCode, &triggerType, &a.TriggerEvent, &status, &a.ProgressPercent, &a.QuizScore, &a.QuizPassed, &a.QuizAttempts, &a.StartedAt, &a.CompletedAt, &a.Deadline, &a.CertificateID, &a.EscalationLevel, &a.LastEscalationAt, &a.EnrollmentID, &a.CreatedAt, &a.UpdatedAt, &a.ModuleCode, &a.ModuleTitle, ) if err != nil { return nil, 0, err } a.Status = AssignmentStatus(status) a.TriggerType = TriggerType(triggerType) assignments = append(assignments, a) } if assignments == nil { assignments = []TrainingAssignment{} } return assignments, total, nil } // UpdateAssignmentStatus updates the status and related fields func (s *Store) UpdateAssignmentStatus(ctx context.Context, id uuid.UUID, status AssignmentStatus, progress int) error { now := time.Now().UTC() _, err := s.pool.Exec(ctx, ` UPDATE training_assignments SET status = $2, progress_percent = $3, started_at = CASE WHEN started_at IS NULL AND $2 IN ('in_progress', 'completed') THEN $4 ELSE started_at END, completed_at = CASE WHEN $2 = 'completed' THEN $4 ELSE completed_at END, updated_at = $4 WHERE id = $1 `, id, string(status), progress, now) return err } // UpdateAssignmentQuizResult updates quiz-related fields on an assignment func (s *Store) UpdateAssignmentQuizResult(ctx context.Context, id uuid.UUID, score float64, passed bool, attempts int) error { now := time.Now().UTC() _, err := s.pool.Exec(ctx, ` UPDATE training_assignments SET quiz_score = $2, quiz_passed = $3, quiz_attempts = $4, status = CASE WHEN $3 = true THEN 'completed' ELSE status END, completed_at = CASE WHEN $3 = true THEN $5 ELSE completed_at END, progress_percent = CASE WHEN $3 = true THEN 100 ELSE progress_percent END, updated_at = $5 WHERE id = $1 `, id, score, passed, attempts, now) return err } // ListOverdueAssignments returns assignments past their deadline func (s *Store) ListOverdueAssignments(ctx context.Context, tenantID uuid.UUID) ([]TrainingAssignment, error) { overdue := true assignments, _, err := s.ListAssignments(ctx, tenantID, &AssignmentFilters{ Overdue: &overdue, Limit: 1000, }) return assignments, err } // ============================================================================ // Quiz Operations // ============================================================================ // CreateQuizQuestion creates a new quiz question func (s *Store) CreateQuizQuestion(ctx context.Context, q *QuizQuestion) error { q.ID = uuid.New() q.CreatedAt = time.Now().UTC() if !q.IsActive { q.IsActive = true } options, _ := json.Marshal(q.Options) _, err := s.pool.Exec(ctx, ` INSERT INTO training_quiz_questions ( id, module_id, question, options, correct_index, explanation, difficulty, is_active, sort_order, created_at ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) `, q.ID, q.ModuleID, q.Question, options, q.CorrectIndex, q.Explanation, string(q.Difficulty), q.IsActive, q.SortOrder, q.CreatedAt, ) return err } // ListQuizQuestions lists quiz questions for a module func (s *Store) ListQuizQuestions(ctx context.Context, moduleID uuid.UUID) ([]QuizQuestion, error) { rows, err := s.pool.Query(ctx, ` SELECT id, module_id, question, options, correct_index, explanation, difficulty, is_active, sort_order, created_at FROM training_quiz_questions WHERE module_id = $1 AND is_active = true ORDER BY sort_order ASC, created_at ASC `, moduleID) if err != nil { return nil, err } defer rows.Close() var questions []QuizQuestion for rows.Next() { var q QuizQuestion var options []byte var difficulty string err := rows.Scan( &q.ID, &q.ModuleID, &q.Question, &options, &q.CorrectIndex, &q.Explanation, &difficulty, &q.IsActive, &q.SortOrder, &q.CreatedAt, ) if err != nil { return nil, err } q.Difficulty = Difficulty(difficulty) json.Unmarshal(options, &q.Options) if q.Options == nil { q.Options = []string{} } questions = append(questions, q) } if questions == nil { questions = []QuizQuestion{} } return questions, nil } // CreateQuizAttempt records a quiz attempt func (s *Store) CreateQuizAttempt(ctx context.Context, attempt *QuizAttempt) error { attempt.ID = uuid.New() attempt.AttemptedAt = time.Now().UTC() answers, _ := json.Marshal(attempt.Answers) _, err := s.pool.Exec(ctx, ` INSERT INTO training_quiz_attempts ( id, assignment_id, user_id, answers, score, passed, correct_count, total_count, duration_seconds, attempted_at ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) `, attempt.ID, attempt.AssignmentID, attempt.UserID, answers, attempt.Score, attempt.Passed, attempt.CorrectCount, attempt.TotalCount, attempt.DurationSeconds, attempt.AttemptedAt, ) return err } // ListQuizAttempts lists quiz attempts for an assignment func (s *Store) ListQuizAttempts(ctx context.Context, assignmentID uuid.UUID) ([]QuizAttempt, error) { rows, err := s.pool.Query(ctx, ` SELECT id, assignment_id, user_id, answers, score, passed, correct_count, total_count, duration_seconds, attempted_at FROM training_quiz_attempts WHERE assignment_id = $1 ORDER BY attempted_at DESC `, assignmentID) if err != nil { return nil, err } defer rows.Close() var attempts []QuizAttempt for rows.Next() { var a QuizAttempt var answers []byte err := rows.Scan( &a.ID, &a.AssignmentID, &a.UserID, &answers, &a.Score, &a.Passed, &a.CorrectCount, &a.TotalCount, &a.DurationSeconds, &a.AttemptedAt, ) if err != nil { return nil, err } json.Unmarshal(answers, &a.Answers) if a.Answers == nil { a.Answers = []QuizAnswer{} } attempts = append(attempts, a) } if attempts == nil { attempts = []QuizAttempt{} } return attempts, nil } // ============================================================================ // Content Operations // ============================================================================ // CreateModuleContent creates new content for a module func (s *Store) CreateModuleContent(ctx context.Context, content *ModuleContent) error { content.ID = uuid.New() content.CreatedAt = time.Now().UTC() content.UpdatedAt = content.CreatedAt // Auto-increment version var maxVersion int s.pool.QueryRow(ctx, "SELECT COALESCE(MAX(version), 0) FROM training_module_content WHERE module_id = $1", content.ModuleID).Scan(&maxVersion) content.Version = maxVersion + 1 _, err := s.pool.Exec(ctx, ` INSERT INTO training_module_content ( id, module_id, version, content_format, content_body, summary, generated_by, llm_model, is_published, reviewed_by, reviewed_at, created_at, updated_at ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) `, content.ID, content.ModuleID, content.Version, string(content.ContentFormat), content.ContentBody, content.Summary, content.GeneratedBy, content.LLMModel, content.IsPublished, content.ReviewedBy, content.ReviewedAt, content.CreatedAt, content.UpdatedAt, ) return err } // GetPublishedContent retrieves the published content for a module func (s *Store) GetPublishedContent(ctx context.Context, moduleID uuid.UUID) (*ModuleContent, error) { var content ModuleContent var contentFormat string err := s.pool.QueryRow(ctx, ` SELECT id, module_id, version, content_format, content_body, summary, generated_by, llm_model, is_published, reviewed_by, reviewed_at, created_at, updated_at FROM training_module_content WHERE module_id = $1 AND is_published = true ORDER BY version DESC LIMIT 1 `, moduleID).Scan( &content.ID, &content.ModuleID, &content.Version, &contentFormat, &content.ContentBody, &content.Summary, &content.GeneratedBy, &content.LLMModel, &content.IsPublished, &content.ReviewedBy, &content.ReviewedAt, &content.CreatedAt, &content.UpdatedAt, ) if err == pgx.ErrNoRows { return nil, nil } if err != nil { return nil, err } content.ContentFormat = ContentFormat(contentFormat) return &content, nil } // GetLatestContent retrieves the latest content (published or not) for a module func (s *Store) GetLatestContent(ctx context.Context, moduleID uuid.UUID) (*ModuleContent, error) { var content ModuleContent var contentFormat string err := s.pool.QueryRow(ctx, ` SELECT id, module_id, version, content_format, content_body, summary, generated_by, llm_model, is_published, reviewed_by, reviewed_at, created_at, updated_at FROM training_module_content WHERE module_id = $1 ORDER BY version DESC LIMIT 1 `, moduleID).Scan( &content.ID, &content.ModuleID, &content.Version, &contentFormat, &content.ContentBody, &content.Summary, &content.GeneratedBy, &content.LLMModel, &content.IsPublished, &content.ReviewedBy, &content.ReviewedAt, &content.CreatedAt, &content.UpdatedAt, ) if err == pgx.ErrNoRows { return nil, nil } if err != nil { return nil, err } content.ContentFormat = ContentFormat(contentFormat) return &content, nil } // PublishContent marks a content version as published (unpublishes all others for that module) func (s *Store) PublishContent(ctx context.Context, contentID uuid.UUID, reviewedBy uuid.UUID) error { now := time.Now().UTC() // Get module_id for this content var moduleID uuid.UUID err := s.pool.QueryRow(ctx, "SELECT module_id FROM training_module_content WHERE id = $1", contentID).Scan(&moduleID) if err != nil { return err } // Unpublish all existing content for this module _, err = s.pool.Exec(ctx, "UPDATE training_module_content SET is_published = false WHERE module_id = $1", moduleID) if err != nil { return err } // Publish the specified content _, err = s.pool.Exec(ctx, ` UPDATE training_module_content SET is_published = true, reviewed_by = $2, reviewed_at = $3, updated_at = $3 WHERE id = $1 `, contentID, reviewedBy, now) return err } // ============================================================================ // Audit Log Operations // ============================================================================ // LogAction creates an audit log entry func (s *Store) LogAction(ctx context.Context, entry *AuditLogEntry) error { entry.ID = uuid.New() entry.CreatedAt = time.Now().UTC() details, _ := json.Marshal(entry.Details) _, err := s.pool.Exec(ctx, ` INSERT INTO training_audit_log ( id, tenant_id, user_id, action, entity_type, entity_id, details, ip_address, created_at ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) `, entry.ID, entry.TenantID, entry.UserID, string(entry.Action), string(entry.EntityType), entry.EntityID, details, entry.IPAddress, entry.CreatedAt, ) return err } // ListAuditLog lists audit log entries for a tenant func (s *Store) ListAuditLog(ctx context.Context, tenantID uuid.UUID, filters *AuditLogFilters) ([]AuditLogEntry, int, error) { countQuery := "SELECT COUNT(*) FROM training_audit_log WHERE tenant_id = $1" countArgs := []interface{}{tenantID} countArgIdx := 2 query := ` SELECT id, tenant_id, user_id, action, entity_type, entity_id, details, ip_address, created_at FROM training_audit_log WHERE tenant_id = $1` args := []interface{}{tenantID} argIdx := 2 if filters != nil { if filters.UserID != nil { query += fmt.Sprintf(" AND user_id = $%d", argIdx) args = append(args, *filters.UserID) argIdx++ countQuery += fmt.Sprintf(" AND user_id = $%d", countArgIdx) countArgs = append(countArgs, *filters.UserID) countArgIdx++ } if filters.Action != "" { query += fmt.Sprintf(" AND action = $%d", argIdx) args = append(args, string(filters.Action)) argIdx++ countQuery += fmt.Sprintf(" AND action = $%d", countArgIdx) countArgs = append(countArgs, string(filters.Action)) countArgIdx++ } if filters.EntityType != "" { query += fmt.Sprintf(" AND entity_type = $%d", argIdx) args = append(args, string(filters.EntityType)) argIdx++ countQuery += fmt.Sprintf(" AND entity_type = $%d", countArgIdx) countArgs = append(countArgs, string(filters.EntityType)) countArgIdx++ } } var total int err := s.pool.QueryRow(ctx, countQuery, countArgs...).Scan(&total) if err != nil { return nil, 0, err } 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) argIdx++ } } rows, err := s.pool.Query(ctx, query, args...) if err != nil { return nil, 0, err } defer rows.Close() var entries []AuditLogEntry for rows.Next() { var entry AuditLogEntry var action, entityType string var details []byte err := rows.Scan( &entry.ID, &entry.TenantID, &entry.UserID, &action, &entityType, &entry.EntityID, &details, &entry.IPAddress, &entry.CreatedAt, ) if err != nil { return nil, 0, err } entry.Action = AuditAction(action) entry.EntityType = AuditEntityType(entityType) json.Unmarshal(details, &entry.Details) if entry.Details == nil { entry.Details = map[string]interface{}{} } entries = append(entries, entry) } if entries == nil { entries = []AuditLogEntry{} } return entries, total, nil } // ============================================================================ // Statistics // ============================================================================ // GetTrainingStats returns aggregated training statistics for a tenant func (s *Store) GetTrainingStats(ctx context.Context, tenantID uuid.UUID) (*TrainingStats, error) { stats := &TrainingStats{} // Total active modules s.pool.QueryRow(ctx, "SELECT COUNT(*) FROM training_modules WHERE tenant_id = $1 AND is_active = true", tenantID).Scan(&stats.TotalModules) // Total assignments s.pool.QueryRow(ctx, "SELECT COUNT(*) FROM training_assignments WHERE tenant_id = $1", tenantID).Scan(&stats.TotalAssignments) // Status counts s.pool.QueryRow(ctx, "SELECT COUNT(*) FROM training_assignments WHERE tenant_id = $1 AND status = 'pending'", tenantID).Scan(&stats.PendingCount) s.pool.QueryRow(ctx, "SELECT COUNT(*) FROM training_assignments WHERE tenant_id = $1 AND status = 'in_progress'", tenantID).Scan(&stats.InProgressCount) s.pool.QueryRow(ctx, "SELECT COUNT(*) FROM training_assignments WHERE tenant_id = $1 AND status = 'completed'", tenantID).Scan(&stats.CompletedCount) // Completion rate if stats.TotalAssignments > 0 { stats.CompletionRate = float64(stats.CompletedCount) / float64(stats.TotalAssignments) * 100 } // Overdue count s.pool.QueryRow(ctx, ` SELECT COUNT(*) FROM training_assignments WHERE tenant_id = $1 AND status IN ('pending', 'in_progress') AND deadline < NOW() `, tenantID).Scan(&stats.OverdueCount) // Average quiz score s.pool.QueryRow(ctx, ` SELECT COALESCE(AVG(quiz_score), 0) FROM training_assignments WHERE tenant_id = $1 AND quiz_score IS NOT NULL `, tenantID).Scan(&stats.AvgQuizScore) // Average completion days s.pool.QueryRow(ctx, ` SELECT COALESCE(AVG(EXTRACT(EPOCH FROM (completed_at - started_at)) / 86400), 0) FROM training_assignments WHERE tenant_id = $1 AND status = 'completed' AND started_at IS NOT NULL AND completed_at IS NOT NULL `, tenantID).Scan(&stats.AvgCompletionDays) // Upcoming deadlines (within 7 days) s.pool.QueryRow(ctx, ` SELECT COUNT(*) FROM training_assignments WHERE tenant_id = $1 AND status IN ('pending', 'in_progress') AND deadline BETWEEN NOW() AND NOW() + INTERVAL '7 days' `, tenantID).Scan(&stats.UpcomingDeadlines) return stats, nil } // GetDeadlines returns upcoming deadlines for a tenant func (s *Store) GetDeadlines(ctx context.Context, tenantID uuid.UUID, limit int) ([]DeadlineInfo, error) { if limit <= 0 { limit = 20 } rows, err := s.pool.Query(ctx, ` SELECT ta.id, m.module_code, m.title, ta.user_id, ta.user_name, ta.deadline, ta.status, EXTRACT(DAY FROM (ta.deadline - NOW()))::INT AS days_left FROM training_assignments ta JOIN training_modules m ON m.id = ta.module_id WHERE ta.tenant_id = $1 AND ta.status IN ('pending', 'in_progress') ORDER BY ta.deadline ASC LIMIT $2 `, tenantID, limit) if err != nil { return nil, err } defer rows.Close() var deadlines []DeadlineInfo for rows.Next() { var d DeadlineInfo var status string err := rows.Scan( &d.AssignmentID, &d.ModuleCode, &d.ModuleTitle, &d.UserID, &d.UserName, &d.Deadline, &status, &d.DaysLeft, ) if err != nil { return nil, err } d.Status = AssignmentStatus(status) deadlines = append(deadlines, d) } if deadlines == nil { deadlines = []DeadlineInfo{} } return deadlines, nil } // ============================================================================ // Media CRUD Operations // ============================================================================ // CreateMedia creates a new media record func (s *Store) CreateMedia(ctx context.Context, media *TrainingMedia) error { media.ID = uuid.New() media.CreatedAt = time.Now().UTC() media.UpdatedAt = media.CreatedAt if media.Metadata == nil { media.Metadata = json.RawMessage("{}") } _, err := s.pool.Exec(ctx, ` INSERT INTO training_media ( id, module_id, content_id, media_type, status, bucket, object_key, file_size_bytes, duration_seconds, mime_type, voice_model, language, metadata, error_message, generated_by, is_published, created_at, updated_at ) VALUES ( $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18 ) `, media.ID, media.ModuleID, media.ContentID, string(media.MediaType), string(media.Status), media.Bucket, media.ObjectKey, media.FileSizeBytes, media.DurationSeconds, media.MimeType, media.VoiceModel, media.Language, media.Metadata, media.ErrorMessage, media.GeneratedBy, media.IsPublished, media.CreatedAt, media.UpdatedAt, ) return err } // GetMedia retrieves a media record by ID func (s *Store) GetMedia(ctx context.Context, id uuid.UUID) (*TrainingMedia, error) { var media TrainingMedia var mediaType, status string err := s.pool.QueryRow(ctx, ` SELECT id, module_id, content_id, media_type, status, bucket, object_key, file_size_bytes, duration_seconds, mime_type, voice_model, language, metadata, error_message, generated_by, is_published, created_at, updated_at FROM training_media WHERE id = $1 `, id).Scan( &media.ID, &media.ModuleID, &media.ContentID, &mediaType, &status, &media.Bucket, &media.ObjectKey, &media.FileSizeBytes, &media.DurationSeconds, &media.MimeType, &media.VoiceModel, &media.Language, &media.Metadata, &media.ErrorMessage, &media.GeneratedBy, &media.IsPublished, &media.CreatedAt, &media.UpdatedAt, ) if err == pgx.ErrNoRows { return nil, nil } if err != nil { return nil, err } media.MediaType = MediaType(mediaType) media.Status = MediaStatus(status) return &media, nil } // GetMediaForModule retrieves all media for a module func (s *Store) GetMediaForModule(ctx context.Context, moduleID uuid.UUID) ([]TrainingMedia, error) { rows, err := s.pool.Query(ctx, ` SELECT id, module_id, content_id, media_type, status, bucket, object_key, file_size_bytes, duration_seconds, mime_type, voice_model, language, metadata, error_message, generated_by, is_published, created_at, updated_at FROM training_media WHERE module_id = $1 ORDER BY media_type, created_at DESC `, moduleID) if err != nil { return nil, err } defer rows.Close() var mediaList []TrainingMedia for rows.Next() { var media TrainingMedia var mediaType, status string if err := rows.Scan( &media.ID, &media.ModuleID, &media.ContentID, &mediaType, &status, &media.Bucket, &media.ObjectKey, &media.FileSizeBytes, &media.DurationSeconds, &media.MimeType, &media.VoiceModel, &media.Language, &media.Metadata, &media.ErrorMessage, &media.GeneratedBy, &media.IsPublished, &media.CreatedAt, &media.UpdatedAt, ); err != nil { return nil, err } media.MediaType = MediaType(mediaType) media.Status = MediaStatus(status) mediaList = append(mediaList, media) } if mediaList == nil { mediaList = []TrainingMedia{} } return mediaList, nil } // UpdateMediaStatus updates the status and related fields of a media record func (s *Store) UpdateMediaStatus(ctx context.Context, id uuid.UUID, status MediaStatus, sizeBytes int64, duration float64, errMsg string) error { _, err := s.pool.Exec(ctx, ` UPDATE training_media SET status = $2, file_size_bytes = $3, duration_seconds = $4, error_message = $5, updated_at = NOW() WHERE id = $1 `, id, string(status), sizeBytes, duration, errMsg) return err } // PublishMedia publishes or unpublishes a media record func (s *Store) PublishMedia(ctx context.Context, id uuid.UUID, publish bool) error { _, err := s.pool.Exec(ctx, ` UPDATE training_media SET is_published = $2, updated_at = NOW() WHERE id = $1 `, id, publish) return err } // GetPublishedAudio gets the published audio for a module func (s *Store) GetPublishedAudio(ctx context.Context, moduleID uuid.UUID) (*TrainingMedia, error) { var media TrainingMedia var mediaType, status string err := s.pool.QueryRow(ctx, ` SELECT id, module_id, content_id, media_type, status, bucket, object_key, file_size_bytes, duration_seconds, mime_type, voice_model, language, metadata, error_message, generated_by, is_published, created_at, updated_at FROM training_media WHERE module_id = $1 AND media_type = 'audio' AND is_published = true ORDER BY created_at DESC LIMIT 1 `, moduleID).Scan( &media.ID, &media.ModuleID, &media.ContentID, &mediaType, &status, &media.Bucket, &media.ObjectKey, &media.FileSizeBytes, &media.DurationSeconds, &media.MimeType, &media.VoiceModel, &media.Language, &media.Metadata, &media.ErrorMessage, &media.GeneratedBy, &media.IsPublished, &media.CreatedAt, &media.UpdatedAt, ) if err == pgx.ErrNoRows { return nil, nil } if err != nil { return nil, err } media.MediaType = MediaType(mediaType) media.Status = MediaStatus(status) return &media, nil } // GetPublishedVideo gets the published video for a module func (s *Store) GetPublishedVideo(ctx context.Context, moduleID uuid.UUID) (*TrainingMedia, error) { var media TrainingMedia var mediaType, status string err := s.pool.QueryRow(ctx, ` SELECT id, module_id, content_id, media_type, status, bucket, object_key, file_size_bytes, duration_seconds, mime_type, voice_model, language, metadata, error_message, generated_by, is_published, created_at, updated_at FROM training_media WHERE module_id = $1 AND media_type = 'video' AND is_published = true ORDER BY created_at DESC LIMIT 1 `, moduleID).Scan( &media.ID, &media.ModuleID, &media.ContentID, &mediaType, &status, &media.Bucket, &media.ObjectKey, &media.FileSizeBytes, &media.DurationSeconds, &media.MimeType, &media.VoiceModel, &media.Language, &media.Metadata, &media.ErrorMessage, &media.GeneratedBy, &media.IsPublished, &media.CreatedAt, &media.UpdatedAt, ) if err == pgx.ErrNoRows { return nil, nil } if err != nil { return nil, err } media.MediaType = MediaType(mediaType) media.Status = MediaStatus(status) return &media, nil }