This repository has been archived on 2026-02-15. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
breakpilot-pwa/ai-compliance-sdk/internal/academy/store.go
BreakPilot Dev 557305db5d
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
feat: Add Academy, Whistleblower, Incidents SDK modules, pitch-deck, blog and CI/CD config
- 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>
2026-02-13 21:12:16 +01:00

667 lines
18 KiB
Go

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
}