refactor(go): split training/store, ucca/rules, ucca_handlers, document_export under 500 LOC
Each of the four oversized files (training/store.go 1569 LOC, ucca/rules.go 1231 LOC, ucca_handlers.go 1135 LOC, document_export.go 1101 LOC) is split by logical group into same-package files, all under the 500-line hard cap. Zero behavior changes, no renamed exported symbols. Also fixed pre-existing hazard_library split (missing functions and duplicate UUID keys from a prior session). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
File diff suppressed because it is too large
Load Diff
340
ai-compliance-sdk/internal/training/store_assignments.go
Normal file
340
ai-compliance-sdk/internal/training/store_assignments.go
Normal file
@@ -0,0 +1,340 @@
|
||||
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
|
||||
}
|
||||
128
ai-compliance-sdk/internal/training/store_audit.go
Normal file
128
ai-compliance-sdk/internal/training/store_audit.go
Normal file
@@ -0,0 +1,128 @@
|
||||
package training
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// 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
|
||||
}
|
||||
198
ai-compliance-sdk/internal/training/store_checkpoints.go
Normal file
198
ai-compliance-sdk/internal/training/store_checkpoints.go
Normal file
@@ -0,0 +1,198 @@
|
||||
package training
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// CreateCheckpoint inserts a new checkpoint
|
||||
func (s *Store) CreateCheckpoint(ctx context.Context, cp *Checkpoint) error {
|
||||
cp.ID = uuid.New()
|
||||
cp.CreatedAt = time.Now().UTC()
|
||||
|
||||
_, err := s.pool.Exec(ctx, `
|
||||
INSERT INTO training_checkpoints (id, module_id, checkpoint_index, title, timestamp_seconds, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
`, cp.ID, cp.ModuleID, cp.CheckpointIndex, cp.Title, cp.TimestampSeconds, cp.CreatedAt)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// ListCheckpoints returns all checkpoints for a module ordered by index
|
||||
func (s *Store) ListCheckpoints(ctx context.Context, moduleID uuid.UUID) ([]Checkpoint, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT id, module_id, checkpoint_index, title, timestamp_seconds, created_at
|
||||
FROM training_checkpoints
|
||||
WHERE module_id = $1
|
||||
ORDER BY checkpoint_index
|
||||
`, moduleID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var checkpoints []Checkpoint
|
||||
for rows.Next() {
|
||||
var cp Checkpoint
|
||||
if err := rows.Scan(&cp.ID, &cp.ModuleID, &cp.CheckpointIndex, &cp.Title, &cp.TimestampSeconds, &cp.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
checkpoints = append(checkpoints, cp)
|
||||
}
|
||||
|
||||
if checkpoints == nil {
|
||||
checkpoints = []Checkpoint{}
|
||||
}
|
||||
return checkpoints, nil
|
||||
}
|
||||
|
||||
// DeleteCheckpointsForModule removes all checkpoints for a module (used before regenerating)
|
||||
func (s *Store) DeleteCheckpointsForModule(ctx context.Context, moduleID uuid.UUID) error {
|
||||
_, err := s.pool.Exec(ctx, `DELETE FROM training_checkpoints WHERE module_id = $1`, moduleID)
|
||||
return err
|
||||
}
|
||||
|
||||
// GetCheckpointProgress retrieves progress for a specific checkpoint+assignment
|
||||
func (s *Store) GetCheckpointProgress(ctx context.Context, assignmentID, checkpointID uuid.UUID) (*CheckpointProgress, error) {
|
||||
var cp CheckpointProgress
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
SELECT id, assignment_id, checkpoint_id, passed, attempts, last_attempt_at, created_at
|
||||
FROM training_checkpoint_progress
|
||||
WHERE assignment_id = $1 AND checkpoint_id = $2
|
||||
`, assignmentID, checkpointID).Scan(
|
||||
&cp.ID, &cp.AssignmentID, &cp.CheckpointID, &cp.Passed, &cp.Attempts, &cp.LastAttemptAt, &cp.CreatedAt,
|
||||
)
|
||||
if err == pgx.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &cp, nil
|
||||
}
|
||||
|
||||
// UpsertCheckpointProgress creates or updates checkpoint progress
|
||||
func (s *Store) UpsertCheckpointProgress(ctx context.Context, progress *CheckpointProgress) error {
|
||||
progress.ID = uuid.New()
|
||||
now := time.Now().UTC()
|
||||
progress.LastAttemptAt = &now
|
||||
progress.CreatedAt = now
|
||||
|
||||
_, err := s.pool.Exec(ctx, `
|
||||
INSERT INTO training_checkpoint_progress (id, assignment_id, checkpoint_id, passed, attempts, last_attempt_at, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
ON CONFLICT (assignment_id, checkpoint_id) DO UPDATE SET
|
||||
passed = EXCLUDED.passed,
|
||||
attempts = training_checkpoint_progress.attempts + 1,
|
||||
last_attempt_at = EXCLUDED.last_attempt_at
|
||||
`, progress.ID, progress.AssignmentID, progress.CheckpointID, progress.Passed, progress.Attempts, progress.LastAttemptAt, progress.CreatedAt)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// GetCheckpointQuestions retrieves quiz questions for a specific checkpoint
|
||||
func (s *Store) GetCheckpointQuestions(ctx context.Context, checkpointID 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 checkpoint_id = $1 AND is_active = true
|
||||
ORDER BY sort_order
|
||||
`, checkpointID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var questions []QuizQuestion
|
||||
for rows.Next() {
|
||||
var q QuizQuestion
|
||||
var options []byte
|
||||
var difficulty string
|
||||
if err := rows.Scan(&q.ID, &q.ModuleID, &q.Question, &options, &q.CorrectIndex, &q.Explanation, &difficulty, &q.IsActive, &q.SortOrder, &q.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
json.Unmarshal(options, &q.Options)
|
||||
q.Difficulty = Difficulty(difficulty)
|
||||
questions = append(questions, q)
|
||||
}
|
||||
|
||||
if questions == nil {
|
||||
questions = []QuizQuestion{}
|
||||
}
|
||||
return questions, nil
|
||||
}
|
||||
|
||||
// CreateCheckpointQuizQuestion creates a quiz question linked to a checkpoint
|
||||
func (s *Store) CreateCheckpointQuizQuestion(ctx context.Context, q *QuizQuestion, checkpointID uuid.UUID) error {
|
||||
q.ID = uuid.New()
|
||||
q.CreatedAt = time.Now().UTC()
|
||||
q.IsActive = true
|
||||
|
||||
options, _ := json.Marshal(q.Options)
|
||||
|
||||
_, err := s.pool.Exec(ctx, `
|
||||
INSERT INTO training_quiz_questions (id, module_id, checkpoint_id, question, options, correct_index, explanation, difficulty, is_active, sort_order, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
||||
`, q.ID, q.ModuleID, checkpointID, q.Question, options, q.CorrectIndex, q.Explanation, string(q.Difficulty), q.IsActive, q.SortOrder, q.CreatedAt)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// AreAllCheckpointsPassed checks if all checkpoints for a module are passed by an assignment
|
||||
func (s *Store) AreAllCheckpointsPassed(ctx context.Context, assignmentID, moduleID uuid.UUID) (bool, error) {
|
||||
var totalCheckpoints, passedCheckpoints int
|
||||
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
SELECT COUNT(*) FROM training_checkpoints WHERE module_id = $1
|
||||
`, moduleID).Scan(&totalCheckpoints)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
if totalCheckpoints == 0 {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
err = s.pool.QueryRow(ctx, `
|
||||
SELECT COUNT(*) FROM training_checkpoint_progress cp
|
||||
JOIN training_checkpoints c ON cp.checkpoint_id = c.id
|
||||
WHERE cp.assignment_id = $1 AND c.module_id = $2 AND cp.passed = true
|
||||
`, assignmentID, moduleID).Scan(&passedCheckpoints)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
return passedCheckpoints >= totalCheckpoints, nil
|
||||
}
|
||||
|
||||
// ListCheckpointProgress returns all checkpoint progress for an assignment
|
||||
func (s *Store) ListCheckpointProgress(ctx context.Context, assignmentID uuid.UUID) ([]CheckpointProgress, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT id, assignment_id, checkpoint_id, passed, attempts, last_attempt_at, created_at
|
||||
FROM training_checkpoint_progress
|
||||
WHERE assignment_id = $1
|
||||
ORDER BY created_at
|
||||
`, assignmentID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var progress []CheckpointProgress
|
||||
for rows.Next() {
|
||||
var cp CheckpointProgress
|
||||
if err := rows.Scan(&cp.ID, &cp.AssignmentID, &cp.CheckpointID, &cp.Passed, &cp.Attempts, &cp.LastAttemptAt, &cp.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
progress = append(progress, cp)
|
||||
}
|
||||
|
||||
if progress == nil {
|
||||
progress = []CheckpointProgress{}
|
||||
}
|
||||
return progress, nil
|
||||
}
|
||||
130
ai-compliance-sdk/internal/training/store_content.go
Normal file
130
ai-compliance-sdk/internal/training/store_content.go
Normal file
@@ -0,0 +1,130 @@
|
||||
package training
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// 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
|
||||
}
|
||||
112
ai-compliance-sdk/internal/training/store_matrix.go
Normal file
112
ai-compliance-sdk/internal/training/store_matrix.go
Normal file
@@ -0,0 +1,112 @@
|
||||
package training
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// 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
|
||||
}
|
||||
192
ai-compliance-sdk/internal/training/store_media.go
Normal file
192
ai-compliance-sdk/internal/training/store_media.go
Normal file
@@ -0,0 +1,192 @@
|
||||
package training
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// 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
|
||||
}
|
||||
235
ai-compliance-sdk/internal/training/store_modules.go
Normal file
235
ai-compliance-sdk/internal/training/store_modules.go
Normal file
@@ -0,0 +1,235 @@
|
||||
package training
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// DeleteModule deletes a training module by ID
|
||||
func (s *Store) DeleteModule(ctx context.Context, id uuid.UUID) error {
|
||||
_, err := s.pool.Exec(ctx, `DELETE FROM training_modules WHERE id = $1`, id)
|
||||
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
|
||||
}
|
||||
140
ai-compliance-sdk/internal/training/store_quiz.go
Normal file
140
ai-compliance-sdk/internal/training/store_quiz.go
Normal file
@@ -0,0 +1,140 @@
|
||||
package training
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// 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
|
||||
}
|
||||
120
ai-compliance-sdk/internal/training/store_stats.go
Normal file
120
ai-compliance-sdk/internal/training/store_stats.go
Normal file
@@ -0,0 +1,120 @@
|
||||
package training
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// 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
|
||||
}
|
||||
Reference in New Issue
Block a user