feat: Add Academy, Whistleblower, Incidents SDK modules, pitch-deck, blog and CI/CD config
Some checks failed
ci/woodpecker/push/integration Pipeline failed
ci/woodpecker/push/main Pipeline failed
CI/CD Pipeline / Go Tests (push) Has been cancelled
CI/CD Pipeline / Python Tests (push) Has been cancelled
CI/CD Pipeline / Website Tests (push) Has been cancelled
CI/CD Pipeline / Linting (push) Has been cancelled
CI/CD Pipeline / Security Scan (push) Has been cancelled
CI/CD Pipeline / Docker Build & Push (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Deploy to Staging (push) Has been cancelled
CI/CD Pipeline / Deploy to Production (push) Has been cancelled
CI/CD Pipeline / CI Summary (push) Has been cancelled
Security Scanning / Secret Scanning (push) Has been cancelled
Security Scanning / Dependency Vulnerability Scan (push) Has been cancelled
Security Scanning / Go Security Scan (push) Has been cancelled
Security Scanning / Python Security Scan (push) Has been cancelled
Security Scanning / Node.js Security Scan (push) Has been cancelled
Security Scanning / Docker Image Security (push) Has been cancelled
Security Scanning / Security Summary (push) Has been cancelled
Tests / Go Tests (push) Has been cancelled
Tests / Python Tests (push) Has been cancelled
Tests / Integration Tests (push) Has been cancelled
Tests / Go Lint (push) Has been cancelled
Tests / Python Lint (push) Has been cancelled
Tests / Security Scan (push) Has been cancelled
Tests / All Checks Passed (push) Has been cancelled
Some checks failed
ci/woodpecker/push/integration Pipeline failed
ci/woodpecker/push/main Pipeline failed
CI/CD Pipeline / Go Tests (push) Has been cancelled
CI/CD Pipeline / Python Tests (push) Has been cancelled
CI/CD Pipeline / Website Tests (push) Has been cancelled
CI/CD Pipeline / Linting (push) Has been cancelled
CI/CD Pipeline / Security Scan (push) Has been cancelled
CI/CD Pipeline / Docker Build & Push (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Deploy to Staging (push) Has been cancelled
CI/CD Pipeline / Deploy to Production (push) Has been cancelled
CI/CD Pipeline / CI Summary (push) Has been cancelled
Security Scanning / Secret Scanning (push) Has been cancelled
Security Scanning / Dependency Vulnerability Scan (push) Has been cancelled
Security Scanning / Go Security Scan (push) Has been cancelled
Security Scanning / Python Security Scan (push) Has been cancelled
Security Scanning / Node.js Security Scan (push) Has been cancelled
Security Scanning / Docker Image Security (push) Has been cancelled
Security Scanning / Security Summary (push) Has been cancelled
Tests / Go Tests (push) Has been cancelled
Tests / Python Tests (push) Has been cancelled
Tests / Integration Tests (push) Has been cancelled
Tests / Go Lint (push) Has been cancelled
Tests / Python Lint (push) Has been cancelled
Tests / Security Scan (push) Has been cancelled
Tests / All Checks Passed (push) Has been cancelled
- Academy, Whistleblower, Incidents frontend pages with API proxies and types - Vendor compliance API proxy route - Go backend handlers and models for all new SDK modules - Investor pitch-deck app with interactive slides - Blog section with DSGVO, AI Act, NIS2, glossary articles - MkDocs documentation site - CI/CD pipelines (Woodpecker, GitHub Actions), security scanning config - Planning and implementation documentation Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
226
ai-compliance-sdk/internal/academy/models.go
Normal file
226
ai-compliance-sdk/internal/academy/models.go
Normal file
@@ -0,0 +1,226 @@
|
||||
package academy
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// ============================================================================
|
||||
// Constants / Enums
|
||||
// ============================================================================
|
||||
|
||||
// CourseCategory represents the category of a compliance course
|
||||
type CourseCategory string
|
||||
|
||||
const (
|
||||
CourseCategoryDSGVOBasics CourseCategory = "dsgvo_basics"
|
||||
CourseCategoryITSecurity CourseCategory = "it_security"
|
||||
CourseCategoryAILiteracy CourseCategory = "ai_literacy"
|
||||
CourseCategoryWhistleblowerProtection CourseCategory = "whistleblower_protection"
|
||||
CourseCategoryCustom CourseCategory = "custom"
|
||||
)
|
||||
|
||||
// EnrollmentStatus represents the status of an enrollment
|
||||
type EnrollmentStatus string
|
||||
|
||||
const (
|
||||
EnrollmentStatusNotStarted EnrollmentStatus = "not_started"
|
||||
EnrollmentStatusInProgress EnrollmentStatus = "in_progress"
|
||||
EnrollmentStatusCompleted EnrollmentStatus = "completed"
|
||||
EnrollmentStatusExpired EnrollmentStatus = "expired"
|
||||
)
|
||||
|
||||
// LessonType represents the type of a lesson
|
||||
type LessonType string
|
||||
|
||||
const (
|
||||
LessonTypeVideo LessonType = "video"
|
||||
LessonTypeText LessonType = "text"
|
||||
LessonTypeQuiz LessonType = "quiz"
|
||||
LessonTypeInteractive LessonType = "interactive"
|
||||
)
|
||||
|
||||
// ============================================================================
|
||||
// Main Entities
|
||||
// ============================================================================
|
||||
|
||||
// Course represents a compliance training course
|
||||
type Course struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
TenantID uuid.UUID `json:"tenant_id"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Category CourseCategory `json:"category"`
|
||||
DurationMinutes int `json:"duration_minutes"`
|
||||
RequiredForRoles []string `json:"required_for_roles"` // JSONB in DB
|
||||
Lessons []Lesson `json:"lessons,omitempty"`
|
||||
IsActive bool `json:"is_active"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// Lesson represents a single lesson within a course
|
||||
type Lesson struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
CourseID uuid.UUID `json:"course_id"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description,omitempty"`
|
||||
LessonType LessonType `json:"lesson_type"`
|
||||
ContentURL string `json:"content_url,omitempty"`
|
||||
DurationMinutes int `json:"duration_minutes"`
|
||||
OrderIndex int `json:"order_index"`
|
||||
QuizQuestions []QuizQuestion `json:"quiz_questions,omitempty"` // JSONB in DB
|
||||
}
|
||||
|
||||
// QuizQuestion represents a single quiz question embedded in a lesson
|
||||
type QuizQuestion struct {
|
||||
Question string `json:"question"`
|
||||
Options []string `json:"options"`
|
||||
CorrectIndex int `json:"correct_index"`
|
||||
Explanation string `json:"explanation"`
|
||||
}
|
||||
|
||||
// Enrollment represents a user's enrollment in a course
|
||||
type Enrollment struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
TenantID uuid.UUID `json:"tenant_id"`
|
||||
CourseID uuid.UUID `json:"course_id"`
|
||||
UserID uuid.UUID `json:"user_id"`
|
||||
UserName string `json:"user_name"`
|
||||
UserEmail string `json:"user_email"`
|
||||
Status EnrollmentStatus `json:"status"`
|
||||
ProgressPercent int `json:"progress_percent"`
|
||||
CurrentLessonIndex int `json:"current_lesson_index"`
|
||||
StartedAt *time.Time `json:"started_at,omitempty"`
|
||||
CompletedAt *time.Time `json:"completed_at,omitempty"`
|
||||
Deadline *time.Time `json:"deadline,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// Certificate represents a completion certificate for an enrollment
|
||||
type Certificate struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
EnrollmentID uuid.UUID `json:"enrollment_id"`
|
||||
UserName string `json:"user_name"`
|
||||
CourseTitle string `json:"course_title"`
|
||||
IssuedAt time.Time `json:"issued_at"`
|
||||
ValidUntil *time.Time `json:"valid_until,omitempty"`
|
||||
PDFURL string `json:"pdf_url,omitempty"`
|
||||
}
|
||||
|
||||
// AcademyStatistics contains aggregated academy metrics
|
||||
type AcademyStatistics struct {
|
||||
TotalCourses int `json:"total_courses"`
|
||||
TotalEnrollments int `json:"total_enrollments"`
|
||||
CompletionRate float64 `json:"completion_rate"` // 0-100
|
||||
OverdueCount int `json:"overdue_count"`
|
||||
AvgCompletionDays float64 `json:"avg_completion_days"`
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Filter Types
|
||||
// ============================================================================
|
||||
|
||||
// CourseFilters defines filters for listing courses
|
||||
type CourseFilters struct {
|
||||
Category CourseCategory
|
||||
IsActive *bool
|
||||
Search string
|
||||
Limit int
|
||||
Offset int
|
||||
}
|
||||
|
||||
// EnrollmentFilters defines filters for listing enrollments
|
||||
type EnrollmentFilters struct {
|
||||
CourseID *uuid.UUID
|
||||
UserID *uuid.UUID
|
||||
Status EnrollmentStatus
|
||||
Limit int
|
||||
Offset int
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// API Request/Response Types
|
||||
// ============================================================================
|
||||
|
||||
// CreateCourseRequest is the API request for creating a course
|
||||
type CreateCourseRequest struct {
|
||||
Title string `json:"title" binding:"required"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Category CourseCategory `json:"category" binding:"required"`
|
||||
DurationMinutes int `json:"duration_minutes"`
|
||||
RequiredForRoles []string `json:"required_for_roles,omitempty"`
|
||||
Lessons []CreateLessonRequest `json:"lessons,omitempty"`
|
||||
}
|
||||
|
||||
// CreateLessonRequest is the API request for creating a lesson
|
||||
type CreateLessonRequest struct {
|
||||
Title string `json:"title" binding:"required"`
|
||||
Description string `json:"description,omitempty"`
|
||||
LessonType LessonType `json:"lesson_type" binding:"required"`
|
||||
ContentURL string `json:"content_url,omitempty"`
|
||||
DurationMinutes int `json:"duration_minutes"`
|
||||
OrderIndex int `json:"order_index"`
|
||||
QuizQuestions []QuizQuestion `json:"quiz_questions,omitempty"`
|
||||
}
|
||||
|
||||
// UpdateCourseRequest is the API request for updating a course
|
||||
type UpdateCourseRequest struct {
|
||||
Title *string `json:"title,omitempty"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
Category *CourseCategory `json:"category,omitempty"`
|
||||
DurationMinutes *int `json:"duration_minutes,omitempty"`
|
||||
RequiredForRoles []string `json:"required_for_roles,omitempty"`
|
||||
IsActive *bool `json:"is_active,omitempty"`
|
||||
}
|
||||
|
||||
// EnrollUserRequest is the API request for enrolling a user in a course
|
||||
type EnrollUserRequest struct {
|
||||
CourseID uuid.UUID `json:"course_id" binding:"required"`
|
||||
UserID uuid.UUID `json:"user_id" binding:"required"`
|
||||
UserName string `json:"user_name" binding:"required"`
|
||||
UserEmail string `json:"user_email" binding:"required"`
|
||||
Deadline *time.Time `json:"deadline,omitempty"`
|
||||
}
|
||||
|
||||
// UpdateProgressRequest is the API request for updating enrollment progress
|
||||
type UpdateProgressRequest struct {
|
||||
Progress int `json:"progress" binding:"required"`
|
||||
CurrentLesson int `json:"current_lesson"`
|
||||
}
|
||||
|
||||
// SubmitQuizRequest is the API request for submitting quiz answers
|
||||
type SubmitQuizRequest struct {
|
||||
LessonID uuid.UUID `json:"lesson_id" binding:"required"`
|
||||
Answers []int `json:"answers" binding:"required"` // Index of selected answer per question
|
||||
}
|
||||
|
||||
// SubmitQuizResponse is the API response for quiz submission
|
||||
type SubmitQuizResponse struct {
|
||||
Score int `json:"score"` // 0-100
|
||||
Passed bool `json:"passed"`
|
||||
CorrectAnswers int `json:"correct_answers"`
|
||||
TotalQuestions int `json:"total_questions"`
|
||||
Results []QuizResult `json:"results"`
|
||||
}
|
||||
|
||||
// QuizResult represents the result for a single quiz question
|
||||
type QuizResult struct {
|
||||
Question string `json:"question"`
|
||||
Correct bool `json:"correct"`
|
||||
Explanation string `json:"explanation"`
|
||||
}
|
||||
|
||||
// CourseListResponse is the API response for listing courses
|
||||
type CourseListResponse struct {
|
||||
Courses []Course `json:"courses"`
|
||||
Total int `json:"total"`
|
||||
}
|
||||
|
||||
// EnrollmentListResponse is the API response for listing enrollments
|
||||
type EnrollmentListResponse struct {
|
||||
Enrollments []Enrollment `json:"enrollments"`
|
||||
Total int `json:"total"`
|
||||
}
|
||||
666
ai-compliance-sdk/internal/academy/store.go
Normal file
666
ai-compliance-sdk/internal/academy/store.go
Normal file
@@ -0,0 +1,666 @@
|
||||
package academy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// Store handles academy data persistence
|
||||
type Store struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
// NewStore creates a new academy store
|
||||
func NewStore(pool *pgxpool.Pool) *Store {
|
||||
return &Store{pool: pool}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Course CRUD Operations
|
||||
// ============================================================================
|
||||
|
||||
// CreateCourse creates a new course
|
||||
func (s *Store) CreateCourse(ctx context.Context, course *Course) error {
|
||||
course.ID = uuid.New()
|
||||
course.CreatedAt = time.Now().UTC()
|
||||
course.UpdatedAt = course.CreatedAt
|
||||
if !course.IsActive {
|
||||
course.IsActive = true
|
||||
}
|
||||
|
||||
requiredForRoles, _ := json.Marshal(course.RequiredForRoles)
|
||||
|
||||
_, err := s.pool.Exec(ctx, `
|
||||
INSERT INTO academy_courses (
|
||||
id, tenant_id, title, description, category,
|
||||
duration_minutes, required_for_roles, is_active,
|
||||
created_at, updated_at
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, $5,
|
||||
$6, $7, $8,
|
||||
$9, $10
|
||||
)
|
||||
`,
|
||||
course.ID, course.TenantID, course.Title, course.Description, string(course.Category),
|
||||
course.DurationMinutes, requiredForRoles, course.IsActive,
|
||||
course.CreatedAt, course.UpdatedAt,
|
||||
)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// GetCourse retrieves a course by ID
|
||||
func (s *Store) GetCourse(ctx context.Context, id uuid.UUID) (*Course, error) {
|
||||
var course Course
|
||||
var category string
|
||||
var requiredForRoles []byte
|
||||
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
SELECT
|
||||
id, tenant_id, title, description, category,
|
||||
duration_minutes, required_for_roles, is_active,
|
||||
created_at, updated_at
|
||||
FROM academy_courses WHERE id = $1
|
||||
`, id).Scan(
|
||||
&course.ID, &course.TenantID, &course.Title, &course.Description, &category,
|
||||
&course.DurationMinutes, &requiredForRoles, &course.IsActive,
|
||||
&course.CreatedAt, &course.UpdatedAt,
|
||||
)
|
||||
|
||||
if err == pgx.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
course.Category = CourseCategory(category)
|
||||
json.Unmarshal(requiredForRoles, &course.RequiredForRoles)
|
||||
if course.RequiredForRoles == nil {
|
||||
course.RequiredForRoles = []string{}
|
||||
}
|
||||
|
||||
// Load lessons for this course
|
||||
lessons, err := s.ListLessons(ctx, course.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
course.Lessons = lessons
|
||||
|
||||
return &course, nil
|
||||
}
|
||||
|
||||
// ListCourses lists courses for a tenant with optional filters
|
||||
func (s *Store) ListCourses(ctx context.Context, tenantID uuid.UUID, filters *CourseFilters) ([]Course, int, error) {
|
||||
// Count query
|
||||
countQuery := "SELECT COUNT(*) FROM academy_courses WHERE tenant_id = $1"
|
||||
countArgs := []interface{}{tenantID}
|
||||
countArgIdx := 2
|
||||
|
||||
// List query
|
||||
query := `
|
||||
SELECT
|
||||
id, tenant_id, title, description, category,
|
||||
duration_minutes, required_for_roles, is_active,
|
||||
created_at, updated_at
|
||||
FROM academy_courses WHERE tenant_id = $1`
|
||||
|
||||
args := []interface{}{tenantID}
|
||||
argIdx := 2
|
||||
|
||||
if filters != nil {
|
||||
if filters.Category != "" {
|
||||
query += fmt.Sprintf(" AND category = $%d", argIdx)
|
||||
args = append(args, string(filters.Category))
|
||||
argIdx++
|
||||
|
||||
countQuery += fmt.Sprintf(" AND category = $%d", countArgIdx)
|
||||
countArgs = append(countArgs, string(filters.Category))
|
||||
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.Search != "" {
|
||||
query += fmt.Sprintf(" AND (title ILIKE $%d OR description ILIKE $%d)", argIdx, argIdx)
|
||||
args = append(args, "%"+filters.Search+"%")
|
||||
argIdx++
|
||||
|
||||
countQuery += fmt.Sprintf(" AND (title ILIKE $%d OR description ILIKE $%d)", countArgIdx, countArgIdx)
|
||||
countArgs = append(countArgs, "%"+filters.Search+"%")
|
||||
countArgIdx++
|
||||
}
|
||||
}
|
||||
|
||||
// Get total count
|
||||
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 courses []Course
|
||||
for rows.Next() {
|
||||
var course Course
|
||||
var category string
|
||||
var requiredForRoles []byte
|
||||
|
||||
err := rows.Scan(
|
||||
&course.ID, &course.TenantID, &course.Title, &course.Description, &category,
|
||||
&course.DurationMinutes, &requiredForRoles, &course.IsActive,
|
||||
&course.CreatedAt, &course.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
course.Category = CourseCategory(category)
|
||||
json.Unmarshal(requiredForRoles, &course.RequiredForRoles)
|
||||
if course.RequiredForRoles == nil {
|
||||
course.RequiredForRoles = []string{}
|
||||
}
|
||||
|
||||
courses = append(courses, course)
|
||||
}
|
||||
|
||||
if courses == nil {
|
||||
courses = []Course{}
|
||||
}
|
||||
|
||||
return courses, total, nil
|
||||
}
|
||||
|
||||
// UpdateCourse updates a course
|
||||
func (s *Store) UpdateCourse(ctx context.Context, course *Course) error {
|
||||
course.UpdatedAt = time.Now().UTC()
|
||||
|
||||
requiredForRoles, _ := json.Marshal(course.RequiredForRoles)
|
||||
|
||||
_, err := s.pool.Exec(ctx, `
|
||||
UPDATE academy_courses SET
|
||||
title = $2, description = $3, category = $4,
|
||||
duration_minutes = $5, required_for_roles = $6, is_active = $7,
|
||||
updated_at = $8
|
||||
WHERE id = $1
|
||||
`,
|
||||
course.ID, course.Title, course.Description, string(course.Category),
|
||||
course.DurationMinutes, requiredForRoles, course.IsActive,
|
||||
course.UpdatedAt,
|
||||
)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// DeleteCourse deletes a course and its related data (via CASCADE)
|
||||
func (s *Store) DeleteCourse(ctx context.Context, id uuid.UUID) error {
|
||||
_, err := s.pool.Exec(ctx, "DELETE FROM academy_courses WHERE id = $1", id)
|
||||
return err
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Lesson Operations
|
||||
// ============================================================================
|
||||
|
||||
// CreateLesson creates a new lesson
|
||||
func (s *Store) CreateLesson(ctx context.Context, lesson *Lesson) error {
|
||||
lesson.ID = uuid.New()
|
||||
|
||||
quizQuestions, _ := json.Marshal(lesson.QuizQuestions)
|
||||
|
||||
_, err := s.pool.Exec(ctx, `
|
||||
INSERT INTO academy_lessons (
|
||||
id, course_id, title, description, lesson_type,
|
||||
content_url, duration_minutes, order_index, quiz_questions
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, $5,
|
||||
$6, $7, $8, $9
|
||||
)
|
||||
`,
|
||||
lesson.ID, lesson.CourseID, lesson.Title, lesson.Description, string(lesson.LessonType),
|
||||
lesson.ContentURL, lesson.DurationMinutes, lesson.OrderIndex, quizQuestions,
|
||||
)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// ListLessons lists lessons for a course ordered by order_index
|
||||
func (s *Store) ListLessons(ctx context.Context, courseID uuid.UUID) ([]Lesson, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT
|
||||
id, course_id, title, description, lesson_type,
|
||||
content_url, duration_minutes, order_index, quiz_questions
|
||||
FROM academy_lessons WHERE course_id = $1
|
||||
ORDER BY order_index ASC
|
||||
`, courseID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var lessons []Lesson
|
||||
for rows.Next() {
|
||||
var lesson Lesson
|
||||
var lessonType string
|
||||
var quizQuestions []byte
|
||||
|
||||
err := rows.Scan(
|
||||
&lesson.ID, &lesson.CourseID, &lesson.Title, &lesson.Description, &lessonType,
|
||||
&lesson.ContentURL, &lesson.DurationMinutes, &lesson.OrderIndex, &quizQuestions,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
lesson.LessonType = LessonType(lessonType)
|
||||
json.Unmarshal(quizQuestions, &lesson.QuizQuestions)
|
||||
if lesson.QuizQuestions == nil {
|
||||
lesson.QuizQuestions = []QuizQuestion{}
|
||||
}
|
||||
|
||||
lessons = append(lessons, lesson)
|
||||
}
|
||||
|
||||
if lessons == nil {
|
||||
lessons = []Lesson{}
|
||||
}
|
||||
|
||||
return lessons, nil
|
||||
}
|
||||
|
||||
// GetLesson retrieves a single lesson by ID
|
||||
func (s *Store) GetLesson(ctx context.Context, id uuid.UUID) (*Lesson, error) {
|
||||
var lesson Lesson
|
||||
var lessonType string
|
||||
var quizQuestions []byte
|
||||
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
SELECT
|
||||
id, course_id, title, description, lesson_type,
|
||||
content_url, duration_minutes, order_index, quiz_questions
|
||||
FROM academy_lessons WHERE id = $1
|
||||
`, id).Scan(
|
||||
&lesson.ID, &lesson.CourseID, &lesson.Title, &lesson.Description, &lessonType,
|
||||
&lesson.ContentURL, &lesson.DurationMinutes, &lesson.OrderIndex, &quizQuestions,
|
||||
)
|
||||
|
||||
if err == pgx.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
lesson.LessonType = LessonType(lessonType)
|
||||
json.Unmarshal(quizQuestions, &lesson.QuizQuestions)
|
||||
if lesson.QuizQuestions == nil {
|
||||
lesson.QuizQuestions = []QuizQuestion{}
|
||||
}
|
||||
|
||||
return &lesson, nil
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Enrollment Operations
|
||||
// ============================================================================
|
||||
|
||||
// CreateEnrollment creates a new enrollment
|
||||
func (s *Store) CreateEnrollment(ctx context.Context, enrollment *Enrollment) error {
|
||||
enrollment.ID = uuid.New()
|
||||
enrollment.CreatedAt = time.Now().UTC()
|
||||
enrollment.UpdatedAt = enrollment.CreatedAt
|
||||
if enrollment.Status == "" {
|
||||
enrollment.Status = EnrollmentStatusNotStarted
|
||||
}
|
||||
|
||||
_, err := s.pool.Exec(ctx, `
|
||||
INSERT INTO academy_enrollments (
|
||||
id, tenant_id, course_id, user_id, user_name, user_email,
|
||||
status, progress_percent, current_lesson_index,
|
||||
started_at, completed_at, deadline,
|
||||
created_at, updated_at
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, $5, $6,
|
||||
$7, $8, $9,
|
||||
$10, $11, $12,
|
||||
$13, $14
|
||||
)
|
||||
`,
|
||||
enrollment.ID, enrollment.TenantID, enrollment.CourseID, enrollment.UserID, enrollment.UserName, enrollment.UserEmail,
|
||||
string(enrollment.Status), enrollment.ProgressPercent, enrollment.CurrentLessonIndex,
|
||||
enrollment.StartedAt, enrollment.CompletedAt, enrollment.Deadline,
|
||||
enrollment.CreatedAt, enrollment.UpdatedAt,
|
||||
)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// GetEnrollment retrieves an enrollment by ID
|
||||
func (s *Store) GetEnrollment(ctx context.Context, id uuid.UUID) (*Enrollment, error) {
|
||||
var enrollment Enrollment
|
||||
var status string
|
||||
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
SELECT
|
||||
id, tenant_id, course_id, user_id, user_name, user_email,
|
||||
status, progress_percent, current_lesson_index,
|
||||
started_at, completed_at, deadline,
|
||||
created_at, updated_at
|
||||
FROM academy_enrollments WHERE id = $1
|
||||
`, id).Scan(
|
||||
&enrollment.ID, &enrollment.TenantID, &enrollment.CourseID, &enrollment.UserID, &enrollment.UserName, &enrollment.UserEmail,
|
||||
&status, &enrollment.ProgressPercent, &enrollment.CurrentLessonIndex,
|
||||
&enrollment.StartedAt, &enrollment.CompletedAt, &enrollment.Deadline,
|
||||
&enrollment.CreatedAt, &enrollment.UpdatedAt,
|
||||
)
|
||||
|
||||
if err == pgx.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
enrollment.Status = EnrollmentStatus(status)
|
||||
return &enrollment, nil
|
||||
}
|
||||
|
||||
// ListEnrollments lists enrollments for a tenant with optional filters
|
||||
func (s *Store) ListEnrollments(ctx context.Context, tenantID uuid.UUID, filters *EnrollmentFilters) ([]Enrollment, int, error) {
|
||||
// Count query
|
||||
countQuery := "SELECT COUNT(*) FROM academy_enrollments WHERE tenant_id = $1"
|
||||
countArgs := []interface{}{tenantID}
|
||||
countArgIdx := 2
|
||||
|
||||
// List query
|
||||
query := `
|
||||
SELECT
|
||||
id, tenant_id, course_id, user_id, user_name, user_email,
|
||||
status, progress_percent, current_lesson_index,
|
||||
started_at, completed_at, deadline,
|
||||
created_at, updated_at
|
||||
FROM academy_enrollments WHERE tenant_id = $1`
|
||||
|
||||
args := []interface{}{tenantID}
|
||||
argIdx := 2
|
||||
|
||||
if filters != nil {
|
||||
if filters.CourseID != nil {
|
||||
query += fmt.Sprintf(" AND course_id = $%d", argIdx)
|
||||
args = append(args, *filters.CourseID)
|
||||
argIdx++
|
||||
|
||||
countQuery += fmt.Sprintf(" AND course_id = $%d", countArgIdx)
|
||||
countArgs = append(countArgs, *filters.CourseID)
|
||||
countArgIdx++
|
||||
}
|
||||
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.Status != "" {
|
||||
query += fmt.Sprintf(" AND status = $%d", argIdx)
|
||||
args = append(args, string(filters.Status))
|
||||
argIdx++
|
||||
|
||||
countQuery += fmt.Sprintf(" AND status = $%d", countArgIdx)
|
||||
countArgs = append(countArgs, string(filters.Status))
|
||||
countArgIdx++
|
||||
}
|
||||
}
|
||||
|
||||
// Get total count
|
||||
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 enrollments []Enrollment
|
||||
for rows.Next() {
|
||||
var enrollment Enrollment
|
||||
var status string
|
||||
|
||||
err := rows.Scan(
|
||||
&enrollment.ID, &enrollment.TenantID, &enrollment.CourseID, &enrollment.UserID, &enrollment.UserName, &enrollment.UserEmail,
|
||||
&status, &enrollment.ProgressPercent, &enrollment.CurrentLessonIndex,
|
||||
&enrollment.StartedAt, &enrollment.CompletedAt, &enrollment.Deadline,
|
||||
&enrollment.CreatedAt, &enrollment.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
enrollment.Status = EnrollmentStatus(status)
|
||||
enrollments = append(enrollments, enrollment)
|
||||
}
|
||||
|
||||
if enrollments == nil {
|
||||
enrollments = []Enrollment{}
|
||||
}
|
||||
|
||||
return enrollments, total, nil
|
||||
}
|
||||
|
||||
// UpdateEnrollmentProgress updates the progress for an enrollment
|
||||
func (s *Store) UpdateEnrollmentProgress(ctx context.Context, id uuid.UUID, progress int, currentLesson int) error {
|
||||
now := time.Now().UTC()
|
||||
|
||||
// If progress > 0, set started_at if not already set and update status to in_progress
|
||||
_, err := s.pool.Exec(ctx, `
|
||||
UPDATE academy_enrollments SET
|
||||
progress_percent = $2,
|
||||
current_lesson_index = $3,
|
||||
status = CASE
|
||||
WHEN $2 >= 100 THEN 'completed'
|
||||
WHEN $2 > 0 THEN 'in_progress'
|
||||
ELSE status
|
||||
END,
|
||||
started_at = CASE
|
||||
WHEN started_at IS NULL AND $2 > 0 THEN $4
|
||||
ELSE started_at
|
||||
END,
|
||||
completed_at = CASE
|
||||
WHEN $2 >= 100 THEN $4
|
||||
ELSE completed_at
|
||||
END,
|
||||
updated_at = $4
|
||||
WHERE id = $1
|
||||
`, id, progress, currentLesson, now)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// CompleteEnrollment marks an enrollment as completed
|
||||
func (s *Store) CompleteEnrollment(ctx context.Context, id uuid.UUID) error {
|
||||
now := time.Now().UTC()
|
||||
|
||||
_, err := s.pool.Exec(ctx, `
|
||||
UPDATE academy_enrollments SET
|
||||
status = 'completed',
|
||||
progress_percent = 100,
|
||||
completed_at = $2,
|
||||
updated_at = $2
|
||||
WHERE id = $1
|
||||
`, id, now)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Certificate Operations
|
||||
// ============================================================================
|
||||
|
||||
// GetCertificate retrieves a certificate by ID
|
||||
func (s *Store) GetCertificate(ctx context.Context, id uuid.UUID) (*Certificate, error) {
|
||||
var cert Certificate
|
||||
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
SELECT
|
||||
id, enrollment_id, user_name, course_title,
|
||||
issued_at, valid_until, pdf_url
|
||||
FROM academy_certificates WHERE id = $1
|
||||
`, id).Scan(
|
||||
&cert.ID, &cert.EnrollmentID, &cert.UserName, &cert.CourseTitle,
|
||||
&cert.IssuedAt, &cert.ValidUntil, &cert.PDFURL,
|
||||
)
|
||||
|
||||
if err == pgx.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &cert, nil
|
||||
}
|
||||
|
||||
// GetCertificateByEnrollment retrieves a certificate by enrollment ID
|
||||
func (s *Store) GetCertificateByEnrollment(ctx context.Context, enrollmentID uuid.UUID) (*Certificate, error) {
|
||||
var cert Certificate
|
||||
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
SELECT
|
||||
id, enrollment_id, user_name, course_title,
|
||||
issued_at, valid_until, pdf_url
|
||||
FROM academy_certificates WHERE enrollment_id = $1
|
||||
`, enrollmentID).Scan(
|
||||
&cert.ID, &cert.EnrollmentID, &cert.UserName, &cert.CourseTitle,
|
||||
&cert.IssuedAt, &cert.ValidUntil, &cert.PDFURL,
|
||||
)
|
||||
|
||||
if err == pgx.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &cert, nil
|
||||
}
|
||||
|
||||
// CreateCertificate creates a new certificate
|
||||
func (s *Store) CreateCertificate(ctx context.Context, cert *Certificate) error {
|
||||
cert.ID = uuid.New()
|
||||
cert.IssuedAt = time.Now().UTC()
|
||||
|
||||
_, err := s.pool.Exec(ctx, `
|
||||
INSERT INTO academy_certificates (
|
||||
id, enrollment_id, user_name, course_title,
|
||||
issued_at, valid_until, pdf_url
|
||||
) VALUES (
|
||||
$1, $2, $3, $4,
|
||||
$5, $6, $7
|
||||
)
|
||||
`,
|
||||
cert.ID, cert.EnrollmentID, cert.UserName, cert.CourseTitle,
|
||||
cert.IssuedAt, cert.ValidUntil, cert.PDFURL,
|
||||
)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Statistics
|
||||
// ============================================================================
|
||||
|
||||
// GetStatistics returns aggregated academy statistics for a tenant
|
||||
func (s *Store) GetStatistics(ctx context.Context, tenantID uuid.UUID) (*AcademyStatistics, error) {
|
||||
stats := &AcademyStatistics{}
|
||||
|
||||
// Total active courses
|
||||
s.pool.QueryRow(ctx,
|
||||
"SELECT COUNT(*) FROM academy_courses WHERE tenant_id = $1 AND is_active = true",
|
||||
tenantID).Scan(&stats.TotalCourses)
|
||||
|
||||
// Total enrollments
|
||||
s.pool.QueryRow(ctx,
|
||||
"SELECT COUNT(*) FROM academy_enrollments WHERE tenant_id = $1",
|
||||
tenantID).Scan(&stats.TotalEnrollments)
|
||||
|
||||
// Completion rate
|
||||
if stats.TotalEnrollments > 0 {
|
||||
var completed int
|
||||
s.pool.QueryRow(ctx,
|
||||
"SELECT COUNT(*) FROM academy_enrollments WHERE tenant_id = $1 AND status = 'completed'",
|
||||
tenantID).Scan(&completed)
|
||||
stats.CompletionRate = float64(completed) / float64(stats.TotalEnrollments) * 100
|
||||
}
|
||||
|
||||
// Overdue count (past deadline, not completed)
|
||||
s.pool.QueryRow(ctx,
|
||||
`SELECT COUNT(*) FROM academy_enrollments
|
||||
WHERE tenant_id = $1
|
||||
AND status NOT IN ('completed', 'expired')
|
||||
AND deadline IS NOT NULL
|
||||
AND deadline < NOW()`,
|
||||
tenantID).Scan(&stats.OverdueCount)
|
||||
|
||||
// Average completion days
|
||||
s.pool.QueryRow(ctx,
|
||||
`SELECT COALESCE(AVG(EXTRACT(EPOCH FROM (completed_at - started_at)) / 86400), 0)
|
||||
FROM academy_enrollments
|
||||
WHERE tenant_id = $1
|
||||
AND status = 'completed'
|
||||
AND started_at IS NOT NULL
|
||||
AND completed_at IS NOT NULL`,
|
||||
tenantID).Scan(&stats.AvgCompletionDays)
|
||||
|
||||
return stats, nil
|
||||
}
|
||||
Reference in New Issue
Block a user