package training import ( "context" "fmt" "time" "github.com/google/uuid" "github.com/jackc/pgx/v5" ) // 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 } // UpdateAssignmentDeadline updates the deadline of an assignment func (s *Store) UpdateAssignmentDeadline(ctx context.Context, id uuid.UUID, deadline time.Time) error { now := time.Now().UTC() _, err := s.pool.Exec(ctx, ` UPDATE training_assignments SET deadline = $2, updated_at = $3 WHERE id = $1 `, id, deadline, 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 } // SetCertificateID sets the certificate ID on an assignment func (s *Store) SetCertificateID(ctx context.Context, assignmentID, certID uuid.UUID) error { _, err := s.pool.Exec(ctx, ` UPDATE training_assignments SET certificate_id = $2, updated_at = NOW() WHERE id = $1 `, assignmentID, certID) return err } // GetAssignmentByCertificateID finds an assignment by its certificate ID func (s *Store) GetAssignmentByCertificateID(ctx context.Context, certID uuid.UUID) (*TrainingAssignment, error) { var assignmentID uuid.UUID err := s.pool.QueryRow(ctx, "SELECT id FROM training_assignments WHERE certificate_id = $1", certID).Scan(&assignmentID) if err == pgx.ErrNoRows { return nil, nil } if err != nil { return nil, err } return s.GetAssignment(ctx, assignmentID) } // ListCertificates lists assignments that have certificates for a tenant func (s *Store) ListCertificates(ctx context.Context, tenantID uuid.UUID) ([]TrainingAssignment, error) { rows, err := s.pool.Query(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.tenant_id = $1 AND ta.certificate_id IS NOT NULL ORDER BY ta.completed_at DESC `, tenantID) if err != nil { return nil, 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, err } a.Status = AssignmentStatus(status) a.TriggerType = TriggerType(triggerType) assignments = append(assignments, a) } if assignments == nil { assignments = []TrainingAssignment{} } return assignments, nil }