feat: Implement Compliance Academy E-Learning module (Phases 1-7)
Some checks 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 / 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
ci/woodpecker/push/integration Pipeline failed
ci/woodpecker/push/main Pipeline failed
Security Scanning / Secret Scanning (push) Has been cancelled
Security Scanning / Dependency Vulnerability Scan (push) Has been cancelled
Security Scanning / Docker Image Security (push) Has been cancelled
Tests / Go Lint (push) Has been cancelled
Tests / All Checks Passed (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 / Python Lint (push) Has been cancelled
Tests / Security Scan (push) Has been cancelled
Some checks 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 / 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
ci/woodpecker/push/integration Pipeline failed
ci/woodpecker/push/main Pipeline failed
Security Scanning / Secret Scanning (push) Has been cancelled
Security Scanning / Dependency Vulnerability Scan (push) Has been cancelled
Security Scanning / Docker Image Security (push) Has been cancelled
Tests / Go Lint (push) Has been cancelled
Tests / All Checks Passed (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 / Python Lint (push) Has been cancelled
Tests / Security Scan (push) Has been cancelled
Add complete Academy backend (Go) and frontend (Next.js) for DSGVO/IT-Security/AI-Literacy compliance training: - Go backend: Course CRUD, enrollments, quiz evaluation, PDF certificates (gofpdf), video generation pipeline (ElevenLabs + HeyGen) - In-memory data store with PostgreSQL migration for future DB support - Frontend: Course creation (AI + manual), lesson viewer, interactive quiz, certificate viewer with PDF download - Fix existing compile errors in generate.go (SearchResult type mismatch), llm/service.go (unused var), rag/service.go (Unicode chars) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
681
admin-v2/ai-compliance-sdk/internal/db/academy_store.go
Normal file
681
admin-v2/ai-compliance-sdk/internal/db/academy_store.go
Normal file
@@ -0,0 +1,681 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// AcademyMemStore provides in-memory storage for academy data
|
||||
type AcademyMemStore struct {
|
||||
mu sync.RWMutex
|
||||
courses map[string]*AcademyCourseRow
|
||||
lessons map[string]*AcademyLessonRow
|
||||
quizQuestions map[string]*AcademyQuizQuestionRow
|
||||
enrollments map[string]*AcademyEnrollmentRow
|
||||
certificates map[string]*AcademyCertificateRow
|
||||
lessonProgress map[string]*AcademyLessonProgressRow
|
||||
}
|
||||
|
||||
// Row types matching the DB schema
|
||||
type AcademyCourseRow struct {
|
||||
ID string
|
||||
TenantID string
|
||||
Title string
|
||||
Description string
|
||||
Category string
|
||||
PassingScore int
|
||||
DurationMinutes int
|
||||
RequiredForRoles []string
|
||||
Status string
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
type AcademyLessonRow struct {
|
||||
ID string
|
||||
CourseID string
|
||||
Title string
|
||||
Type string
|
||||
ContentMarkdown string
|
||||
VideoURL string
|
||||
AudioURL string
|
||||
SortOrder int
|
||||
DurationMinutes int
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
type AcademyQuizQuestionRow struct {
|
||||
ID string
|
||||
LessonID string
|
||||
Question string
|
||||
Options []string
|
||||
CorrectOptionIndex int
|
||||
Explanation string
|
||||
SortOrder int
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
type AcademyEnrollmentRow struct {
|
||||
ID string
|
||||
TenantID string
|
||||
CourseID string
|
||||
UserID string
|
||||
UserName string
|
||||
UserEmail string
|
||||
Status string
|
||||
Progress int
|
||||
StartedAt time.Time
|
||||
CompletedAt *time.Time
|
||||
CertificateID string
|
||||
Deadline time.Time
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
type AcademyCertificateRow struct {
|
||||
ID string
|
||||
TenantID string
|
||||
EnrollmentID string
|
||||
CourseID string
|
||||
UserID string
|
||||
UserName string
|
||||
CourseName string
|
||||
Score int
|
||||
IssuedAt time.Time
|
||||
ValidUntil time.Time
|
||||
PdfURL string
|
||||
}
|
||||
|
||||
type AcademyLessonProgressRow struct {
|
||||
ID string
|
||||
EnrollmentID string
|
||||
LessonID string
|
||||
Completed bool
|
||||
QuizScore *int
|
||||
CompletedAt *time.Time
|
||||
}
|
||||
|
||||
type AcademyStatisticsRow struct {
|
||||
TotalCourses int
|
||||
TotalEnrollments int
|
||||
CompletionRate float64
|
||||
OverdueCount int
|
||||
ByCategory map[string]int
|
||||
ByStatus map[string]int
|
||||
}
|
||||
|
||||
func NewAcademyMemStore() *AcademyMemStore {
|
||||
return &AcademyMemStore{
|
||||
courses: make(map[string]*AcademyCourseRow),
|
||||
lessons: make(map[string]*AcademyLessonRow),
|
||||
quizQuestions: make(map[string]*AcademyQuizQuestionRow),
|
||||
enrollments: make(map[string]*AcademyEnrollmentRow),
|
||||
certificates: make(map[string]*AcademyCertificateRow),
|
||||
lessonProgress: make(map[string]*AcademyLessonProgressRow),
|
||||
}
|
||||
}
|
||||
|
||||
// generateID creates a simple unique ID
|
||||
func generateID() string {
|
||||
return fmt.Sprintf("%d", time.Now().UnixNano())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Course CRUD
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// ListCourses returns all courses for a tenant, sorted by UpdatedAt DESC.
|
||||
func (s *AcademyMemStore) ListCourses(tenantID string) []*AcademyCourseRow {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
var result []*AcademyCourseRow
|
||||
for _, c := range s.courses {
|
||||
if c.TenantID == tenantID {
|
||||
result = append(result, c)
|
||||
}
|
||||
}
|
||||
|
||||
sort.Slice(result, func(i, j int) bool {
|
||||
return result[i].UpdatedAt.After(result[j].UpdatedAt)
|
||||
})
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// GetCourse retrieves a single course by ID.
|
||||
func (s *AcademyMemStore) GetCourse(id string) (*AcademyCourseRow, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
c, ok := s.courses[id]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("course not found: %s", id)
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// CreateCourse inserts a new course with auto-generated ID and timestamps.
|
||||
func (s *AcademyMemStore) CreateCourse(row *AcademyCourseRow) *AcademyCourseRow {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
now := time.Now()
|
||||
row.ID = generateID()
|
||||
row.CreatedAt = now
|
||||
row.UpdatedAt = now
|
||||
s.courses[row.ID] = row
|
||||
return row
|
||||
}
|
||||
|
||||
// UpdateCourse partially updates a course. Supported keys: Title, Description,
|
||||
// Category, PassingScore, DurationMinutes, RequiredForRoles, Status.
|
||||
func (s *AcademyMemStore) UpdateCourse(id string, updates map[string]interface{}) (*AcademyCourseRow, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
c, ok := s.courses[id]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("course not found: %s", id)
|
||||
}
|
||||
|
||||
for k, v := range updates {
|
||||
switch strings.ToLower(k) {
|
||||
case "title":
|
||||
if val, ok := v.(string); ok {
|
||||
c.Title = val
|
||||
}
|
||||
case "description":
|
||||
if val, ok := v.(string); ok {
|
||||
c.Description = val
|
||||
}
|
||||
case "category":
|
||||
if val, ok := v.(string); ok {
|
||||
c.Category = val
|
||||
}
|
||||
case "passingscore", "passing_score":
|
||||
switch val := v.(type) {
|
||||
case int:
|
||||
c.PassingScore = val
|
||||
case float64:
|
||||
c.PassingScore = int(val)
|
||||
}
|
||||
case "durationminutes", "duration_minutes":
|
||||
switch val := v.(type) {
|
||||
case int:
|
||||
c.DurationMinutes = val
|
||||
case float64:
|
||||
c.DurationMinutes = int(val)
|
||||
}
|
||||
case "requiredforroles", "required_for_roles":
|
||||
if val, ok := v.([]string); ok {
|
||||
c.RequiredForRoles = val
|
||||
}
|
||||
case "status":
|
||||
if val, ok := v.(string); ok {
|
||||
c.Status = val
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
c.UpdatedAt = time.Now()
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// DeleteCourse removes a course and all related lessons, quiz questions,
|
||||
// enrollments, certificates, and lesson progress.
|
||||
func (s *AcademyMemStore) DeleteCourse(id string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if _, ok := s.courses[id]; !ok {
|
||||
return fmt.Errorf("course not found: %s", id)
|
||||
}
|
||||
|
||||
// Collect lesson IDs for this course
|
||||
lessonIDs := make(map[string]bool)
|
||||
for lid, l := range s.lessons {
|
||||
if l.CourseID == id {
|
||||
lessonIDs[lid] = true
|
||||
}
|
||||
}
|
||||
|
||||
// Delete quiz questions belonging to those lessons
|
||||
for qid, q := range s.quizQuestions {
|
||||
if lessonIDs[q.LessonID] {
|
||||
delete(s.quizQuestions, qid)
|
||||
}
|
||||
}
|
||||
|
||||
// Delete lessons
|
||||
for lid := range lessonIDs {
|
||||
delete(s.lessons, lid)
|
||||
}
|
||||
|
||||
// Collect enrollment IDs for this course
|
||||
enrollmentIDs := make(map[string]bool)
|
||||
for eid, e := range s.enrollments {
|
||||
if e.CourseID == id {
|
||||
enrollmentIDs[eid] = true
|
||||
}
|
||||
}
|
||||
|
||||
// Delete lesson progress belonging to those enrollments
|
||||
for pid, p := range s.lessonProgress {
|
||||
if enrollmentIDs[p.EnrollmentID] {
|
||||
delete(s.lessonProgress, pid)
|
||||
}
|
||||
}
|
||||
|
||||
// Delete certificates belonging to those enrollments
|
||||
for cid, cert := range s.certificates {
|
||||
if cert.CourseID == id {
|
||||
delete(s.certificates, cid)
|
||||
}
|
||||
}
|
||||
|
||||
// Delete enrollments
|
||||
for eid := range enrollmentIDs {
|
||||
delete(s.enrollments, eid)
|
||||
}
|
||||
|
||||
// Delete the course itself
|
||||
delete(s.courses, id)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Lesson CRUD
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// ListLessons returns all lessons for a course, sorted by SortOrder ASC.
|
||||
func (s *AcademyMemStore) ListLessons(courseID string) []*AcademyLessonRow {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
var result []*AcademyLessonRow
|
||||
for _, l := range s.lessons {
|
||||
if l.CourseID == courseID {
|
||||
result = append(result, l)
|
||||
}
|
||||
}
|
||||
|
||||
sort.Slice(result, func(i, j int) bool {
|
||||
return result[i].SortOrder < result[j].SortOrder
|
||||
})
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// GetLesson retrieves a single lesson by ID.
|
||||
func (s *AcademyMemStore) GetLesson(id string) (*AcademyLessonRow, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
l, ok := s.lessons[id]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("lesson not found: %s", id)
|
||||
}
|
||||
return l, nil
|
||||
}
|
||||
|
||||
// CreateLesson inserts a new lesson with auto-generated ID and timestamps.
|
||||
func (s *AcademyMemStore) CreateLesson(row *AcademyLessonRow) *AcademyLessonRow {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
now := time.Now()
|
||||
row.ID = generateID()
|
||||
row.CreatedAt = now
|
||||
row.UpdatedAt = now
|
||||
s.lessons[row.ID] = row
|
||||
return row
|
||||
}
|
||||
|
||||
// UpdateLesson partially updates a lesson. Supported keys: Title, Type,
|
||||
// ContentMarkdown, VideoURL, AudioURL, SortOrder, DurationMinutes.
|
||||
func (s *AcademyMemStore) UpdateLesson(id string, updates map[string]interface{}) (*AcademyLessonRow, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
l, ok := s.lessons[id]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("lesson not found: %s", id)
|
||||
}
|
||||
|
||||
for k, v := range updates {
|
||||
switch strings.ToLower(k) {
|
||||
case "title":
|
||||
if val, ok := v.(string); ok {
|
||||
l.Title = val
|
||||
}
|
||||
case "type":
|
||||
if val, ok := v.(string); ok {
|
||||
l.Type = val
|
||||
}
|
||||
case "contentmarkdown", "content_markdown":
|
||||
if val, ok := v.(string); ok {
|
||||
l.ContentMarkdown = val
|
||||
}
|
||||
case "videourl", "video_url":
|
||||
if val, ok := v.(string); ok {
|
||||
l.VideoURL = val
|
||||
}
|
||||
case "audiourl", "audio_url":
|
||||
if val, ok := v.(string); ok {
|
||||
l.AudioURL = val
|
||||
}
|
||||
case "sortorder", "sort_order":
|
||||
switch val := v.(type) {
|
||||
case int:
|
||||
l.SortOrder = val
|
||||
case float64:
|
||||
l.SortOrder = int(val)
|
||||
}
|
||||
case "durationminutes", "duration_minutes":
|
||||
switch val := v.(type) {
|
||||
case int:
|
||||
l.DurationMinutes = val
|
||||
case float64:
|
||||
l.DurationMinutes = int(val)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
l.UpdatedAt = time.Now()
|
||||
return l, nil
|
||||
}
|
||||
|
||||
// DeleteLesson removes a lesson and its quiz questions.
|
||||
func (s *AcademyMemStore) DeleteLesson(id string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if _, ok := s.lessons[id]; !ok {
|
||||
return fmt.Errorf("lesson not found: %s", id)
|
||||
}
|
||||
|
||||
// Delete quiz questions belonging to this lesson
|
||||
for qid, q := range s.quizQuestions {
|
||||
if q.LessonID == id {
|
||||
delete(s.quizQuestions, qid)
|
||||
}
|
||||
}
|
||||
|
||||
delete(s.lessons, id)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Quiz Questions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// ListQuizQuestions returns all quiz questions for a lesson, sorted by SortOrder ASC.
|
||||
func (s *AcademyMemStore) ListQuizQuestions(lessonID string) []*AcademyQuizQuestionRow {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
var result []*AcademyQuizQuestionRow
|
||||
for _, q := range s.quizQuestions {
|
||||
if q.LessonID == lessonID {
|
||||
result = append(result, q)
|
||||
}
|
||||
}
|
||||
|
||||
sort.Slice(result, func(i, j int) bool {
|
||||
return result[i].SortOrder < result[j].SortOrder
|
||||
})
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// CreateQuizQuestion inserts a new quiz question with auto-generated ID and timestamp.
|
||||
func (s *AcademyMemStore) CreateQuizQuestion(row *AcademyQuizQuestionRow) *AcademyQuizQuestionRow {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
row.ID = generateID()
|
||||
row.CreatedAt = time.Now()
|
||||
s.quizQuestions[row.ID] = row
|
||||
return row
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Enrollments
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// ListEnrollments returns enrollments filtered by tenantID and optionally by courseID.
|
||||
// If courseID is empty, all enrollments for the tenant are returned.
|
||||
func (s *AcademyMemStore) ListEnrollments(tenantID string, courseID string) []*AcademyEnrollmentRow {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
var result []*AcademyEnrollmentRow
|
||||
for _, e := range s.enrollments {
|
||||
if e.TenantID != tenantID {
|
||||
continue
|
||||
}
|
||||
if courseID != "" && e.CourseID != courseID {
|
||||
continue
|
||||
}
|
||||
result = append(result, e)
|
||||
}
|
||||
|
||||
sort.Slice(result, func(i, j int) bool {
|
||||
return result[i].UpdatedAt.After(result[j].UpdatedAt)
|
||||
})
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// GetEnrollment retrieves a single enrollment by ID.
|
||||
func (s *AcademyMemStore) GetEnrollment(id string) (*AcademyEnrollmentRow, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
e, ok := s.enrollments[id]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("enrollment not found: %s", id)
|
||||
}
|
||||
return e, nil
|
||||
}
|
||||
|
||||
// CreateEnrollment inserts a new enrollment with auto-generated ID and timestamps.
|
||||
func (s *AcademyMemStore) CreateEnrollment(row *AcademyEnrollmentRow) *AcademyEnrollmentRow {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
now := time.Now()
|
||||
row.ID = generateID()
|
||||
row.CreatedAt = now
|
||||
row.UpdatedAt = now
|
||||
if row.StartedAt.IsZero() {
|
||||
row.StartedAt = now
|
||||
}
|
||||
s.enrollments[row.ID] = row
|
||||
return row
|
||||
}
|
||||
|
||||
// UpdateEnrollment partially updates an enrollment. Supported keys: Status,
|
||||
// Progress, CompletedAt, CertificateID, Deadline.
|
||||
func (s *AcademyMemStore) UpdateEnrollment(id string, updates map[string]interface{}) (*AcademyEnrollmentRow, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
e, ok := s.enrollments[id]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("enrollment not found: %s", id)
|
||||
}
|
||||
|
||||
for k, v := range updates {
|
||||
switch strings.ToLower(k) {
|
||||
case "status":
|
||||
if val, ok := v.(string); ok {
|
||||
e.Status = val
|
||||
}
|
||||
case "progress":
|
||||
switch val := v.(type) {
|
||||
case int:
|
||||
e.Progress = val
|
||||
case float64:
|
||||
e.Progress = int(val)
|
||||
}
|
||||
case "completedat", "completed_at":
|
||||
if val, ok := v.(*time.Time); ok {
|
||||
e.CompletedAt = val
|
||||
} else if val, ok := v.(time.Time); ok {
|
||||
e.CompletedAt = &val
|
||||
}
|
||||
case "certificateid", "certificate_id":
|
||||
if val, ok := v.(string); ok {
|
||||
e.CertificateID = val
|
||||
}
|
||||
case "deadline":
|
||||
if val, ok := v.(time.Time); ok {
|
||||
e.Deadline = val
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
e.UpdatedAt = time.Now()
|
||||
return e, nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Certificates
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// GetCertificate retrieves a certificate by ID.
|
||||
func (s *AcademyMemStore) GetCertificate(id string) (*AcademyCertificateRow, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
cert, ok := s.certificates[id]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("certificate not found: %s", id)
|
||||
}
|
||||
return cert, nil
|
||||
}
|
||||
|
||||
// GetCertificateByEnrollment retrieves a certificate by enrollment ID.
|
||||
func (s *AcademyMemStore) GetCertificateByEnrollment(enrollmentID string) (*AcademyCertificateRow, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
for _, cert := range s.certificates {
|
||||
if cert.EnrollmentID == enrollmentID {
|
||||
return cert, nil
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("certificate not found for enrollment: %s", enrollmentID)
|
||||
}
|
||||
|
||||
// CreateCertificate inserts a new certificate with auto-generated ID.
|
||||
func (s *AcademyMemStore) CreateCertificate(row *AcademyCertificateRow) *AcademyCertificateRow {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
row.ID = generateID()
|
||||
if row.IssuedAt.IsZero() {
|
||||
row.IssuedAt = time.Now()
|
||||
}
|
||||
s.certificates[row.ID] = row
|
||||
return row
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Lesson Progress
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// ListLessonProgress returns all progress entries for an enrollment.
|
||||
func (s *AcademyMemStore) ListLessonProgress(enrollmentID string) []*AcademyLessonProgressRow {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
var result []*AcademyLessonProgressRow
|
||||
for _, p := range s.lessonProgress {
|
||||
if p.EnrollmentID == enrollmentID {
|
||||
result = append(result, p)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// UpsertLessonProgress inserts or updates a lesson progress entry.
|
||||
// Matching is done by EnrollmentID + LessonID composite key.
|
||||
func (s *AcademyMemStore) UpsertLessonProgress(row *AcademyLessonProgressRow) *AcademyLessonProgressRow {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
// Look for existing entry with same enrollment_id + lesson_id
|
||||
for _, p := range s.lessonProgress {
|
||||
if p.EnrollmentID == row.EnrollmentID && p.LessonID == row.LessonID {
|
||||
p.Completed = row.Completed
|
||||
p.QuizScore = row.QuizScore
|
||||
p.CompletedAt = row.CompletedAt
|
||||
return p
|
||||
}
|
||||
}
|
||||
|
||||
// Insert new entry
|
||||
row.ID = generateID()
|
||||
s.lessonProgress[row.ID] = row
|
||||
return row
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Statistics
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// GetStatistics computes aggregate statistics for a tenant.
|
||||
func (s *AcademyMemStore) GetStatistics(tenantID string) *AcademyStatisticsRow {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
stats := &AcademyStatisticsRow{
|
||||
ByCategory: make(map[string]int),
|
||||
ByStatus: make(map[string]int),
|
||||
}
|
||||
|
||||
// Count courses by category
|
||||
for _, c := range s.courses {
|
||||
if c.TenantID != tenantID {
|
||||
continue
|
||||
}
|
||||
stats.TotalCourses++
|
||||
if c.Category != "" {
|
||||
stats.ByCategory[c.Category]++
|
||||
}
|
||||
}
|
||||
|
||||
// Count enrollments and compute completion rate
|
||||
var completedCount int
|
||||
now := time.Now()
|
||||
for _, e := range s.enrollments {
|
||||
if e.TenantID != tenantID {
|
||||
continue
|
||||
}
|
||||
stats.TotalEnrollments++
|
||||
stats.ByStatus[e.Status]++
|
||||
|
||||
if e.Status == "completed" {
|
||||
completedCount++
|
||||
}
|
||||
|
||||
// Overdue: not completed and past deadline
|
||||
if e.Status != "completed" && !e.Deadline.IsZero() && now.After(e.Deadline) {
|
||||
stats.OverdueCount++
|
||||
}
|
||||
}
|
||||
|
||||
if stats.TotalEnrollments > 0 {
|
||||
stats.CompletionRate = float64(completedCount) / float64(stats.TotalEnrollments) * 100.0
|
||||
}
|
||||
|
||||
return stats
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
-- Migration: Create Academy Tables
|
||||
-- Description: Schema for the Compliance Academy module (courses, lessons, quizzes, enrollments, certificates, progress)
|
||||
|
||||
-- Enable UUID extension if not already enabled
|
||||
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
||||
|
||||
-- ============================================================================
|
||||
-- 1. academy_courses - Training courses for compliance education
|
||||
-- ============================================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS academy_courses (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
tenant_id VARCHAR(255) NOT NULL,
|
||||
title VARCHAR(255) NOT NULL,
|
||||
description TEXT,
|
||||
category VARCHAR(50),
|
||||
passing_score INTEGER DEFAULT 70,
|
||||
duration_minutes INTEGER,
|
||||
required_for_roles JSONB DEFAULT '["all"]',
|
||||
status VARCHAR(50) DEFAULT 'draft',
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Indexes for academy_courses
|
||||
CREATE INDEX IF NOT EXISTS idx_academy_courses_tenant ON academy_courses(tenant_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_academy_courses_status ON academy_courses(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_academy_courses_category ON academy_courses(category);
|
||||
|
||||
-- Auto-update trigger for academy_courses.updated_at
|
||||
CREATE OR REPLACE FUNCTION update_academy_courses_updated_at()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = NOW();
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
DROP TRIGGER IF EXISTS trigger_academy_courses_updated_at ON academy_courses;
|
||||
CREATE TRIGGER trigger_academy_courses_updated_at
|
||||
BEFORE UPDATE ON academy_courses
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_academy_courses_updated_at();
|
||||
|
||||
-- Comments for academy_courses
|
||||
COMMENT ON TABLE academy_courses IS 'Stores compliance training courses per tenant';
|
||||
COMMENT ON COLUMN academy_courses.tenant_id IS 'Identifier for the tenant owning this course';
|
||||
COMMENT ON COLUMN academy_courses.title IS 'Course title displayed to users';
|
||||
COMMENT ON COLUMN academy_courses.category IS 'Course category (e.g. dsgvo, ai-act, security)';
|
||||
COMMENT ON COLUMN academy_courses.passing_score IS 'Minimum score (0-100) required to pass the course';
|
||||
COMMENT ON COLUMN academy_courses.duration_minutes IS 'Estimated total duration of the course in minutes';
|
||||
COMMENT ON COLUMN academy_courses.required_for_roles IS 'JSON array of roles required to complete this course';
|
||||
COMMENT ON COLUMN academy_courses.status IS 'Course status: draft, published, archived';
|
||||
|
||||
-- ============================================================================
|
||||
-- 2. academy_lessons - Individual lessons within a course
|
||||
-- ============================================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS academy_lessons (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
course_id UUID NOT NULL REFERENCES academy_courses(id) ON DELETE CASCADE,
|
||||
title VARCHAR(255) NOT NULL,
|
||||
type VARCHAR(20) NOT NULL,
|
||||
content_markdown TEXT,
|
||||
video_url VARCHAR(500),
|
||||
audio_url VARCHAR(500),
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
duration_minutes INTEGER,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Indexes for academy_lessons
|
||||
CREATE INDEX IF NOT EXISTS idx_academy_lessons_course ON academy_lessons(course_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_academy_lessons_sort ON academy_lessons(course_id, sort_order);
|
||||
|
||||
-- Auto-update trigger for academy_lessons.updated_at
|
||||
CREATE OR REPLACE FUNCTION update_academy_lessons_updated_at()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = NOW();
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
DROP TRIGGER IF EXISTS trigger_academy_lessons_updated_at ON academy_lessons;
|
||||
CREATE TRIGGER trigger_academy_lessons_updated_at
|
||||
BEFORE UPDATE ON academy_lessons
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_academy_lessons_updated_at();
|
||||
|
||||
-- Comments for academy_lessons
|
||||
COMMENT ON TABLE academy_lessons IS 'Individual lessons belonging to a course';
|
||||
COMMENT ON COLUMN academy_lessons.course_id IS 'Foreign key to the parent course';
|
||||
COMMENT ON COLUMN academy_lessons.type IS 'Lesson type: text, video, audio, quiz, interactive';
|
||||
COMMENT ON COLUMN academy_lessons.content_markdown IS 'Lesson content in Markdown format';
|
||||
COMMENT ON COLUMN academy_lessons.video_url IS 'URL to video content (if type is video)';
|
||||
COMMENT ON COLUMN academy_lessons.audio_url IS 'URL to audio content (if type is audio)';
|
||||
COMMENT ON COLUMN academy_lessons.sort_order IS 'Order of the lesson within the course';
|
||||
COMMENT ON COLUMN academy_lessons.duration_minutes IS 'Estimated duration of this lesson in minutes';
|
||||
|
||||
-- ============================================================================
|
||||
-- 3. academy_quiz_questions - Quiz questions attached to lessons
|
||||
-- ============================================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS academy_quiz_questions (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
lesson_id UUID NOT NULL REFERENCES academy_lessons(id) ON DELETE CASCADE,
|
||||
question TEXT NOT NULL,
|
||||
options JSONB NOT NULL,
|
||||
correct_option_index INTEGER NOT NULL,
|
||||
explanation TEXT,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Indexes for academy_quiz_questions
|
||||
CREATE INDEX IF NOT EXISTS idx_academy_quiz_questions_lesson ON academy_quiz_questions(lesson_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_academy_quiz_questions_sort ON academy_quiz_questions(lesson_id, sort_order);
|
||||
|
||||
-- Comments for academy_quiz_questions
|
||||
COMMENT ON TABLE academy_quiz_questions IS 'Quiz questions belonging to a lesson';
|
||||
COMMENT ON COLUMN academy_quiz_questions.lesson_id IS 'Foreign key to the parent lesson';
|
||||
COMMENT ON COLUMN academy_quiz_questions.question IS 'The question text';
|
||||
COMMENT ON COLUMN academy_quiz_questions.options IS 'JSON array of answer options (strings)';
|
||||
COMMENT ON COLUMN academy_quiz_questions.correct_option_index IS 'Zero-based index of the correct option';
|
||||
COMMENT ON COLUMN academy_quiz_questions.explanation IS 'Explanation shown after answering (correct or incorrect)';
|
||||
COMMENT ON COLUMN academy_quiz_questions.sort_order IS 'Order of the question within the lesson quiz';
|
||||
|
||||
-- ============================================================================
|
||||
-- 4. academy_enrollments - User enrollments in courses
|
||||
-- ============================================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS academy_enrollments (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
tenant_id VARCHAR(255) NOT NULL,
|
||||
course_id UUID NOT NULL REFERENCES academy_courses(id) ON DELETE CASCADE,
|
||||
user_id VARCHAR(255) NOT NULL,
|
||||
user_name VARCHAR(255),
|
||||
user_email VARCHAR(255),
|
||||
status VARCHAR(20) DEFAULT 'not_started',
|
||||
progress INTEGER DEFAULT 0,
|
||||
started_at TIMESTAMP WITH TIME ZONE,
|
||||
completed_at TIMESTAMP WITH TIME ZONE,
|
||||
certificate_id UUID,
|
||||
deadline TIMESTAMP WITH TIME ZONE,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Indexes for academy_enrollments
|
||||
CREATE INDEX IF NOT EXISTS idx_academy_enrollments_tenant ON academy_enrollments(tenant_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_academy_enrollments_course ON academy_enrollments(course_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_academy_enrollments_user ON academy_enrollments(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_academy_enrollments_status ON academy_enrollments(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_academy_enrollments_tenant_user ON academy_enrollments(tenant_id, user_id);
|
||||
|
||||
-- Auto-update trigger for academy_enrollments.updated_at
|
||||
CREATE OR REPLACE FUNCTION update_academy_enrollments_updated_at()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = NOW();
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
DROP TRIGGER IF EXISTS trigger_academy_enrollments_updated_at ON academy_enrollments;
|
||||
CREATE TRIGGER trigger_academy_enrollments_updated_at
|
||||
BEFORE UPDATE ON academy_enrollments
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_academy_enrollments_updated_at();
|
||||
|
||||
-- Comments for academy_enrollments
|
||||
COMMENT ON TABLE academy_enrollments IS 'Tracks user enrollments and progress in courses';
|
||||
COMMENT ON COLUMN academy_enrollments.tenant_id IS 'Identifier for the tenant';
|
||||
COMMENT ON COLUMN academy_enrollments.course_id IS 'Foreign key to the enrolled course';
|
||||
COMMENT ON COLUMN academy_enrollments.user_id IS 'Identifier of the enrolled user';
|
||||
COMMENT ON COLUMN academy_enrollments.user_name IS 'Display name of the enrolled user';
|
||||
COMMENT ON COLUMN academy_enrollments.user_email IS 'Email address of the enrolled user';
|
||||
COMMENT ON COLUMN academy_enrollments.status IS 'Enrollment status: not_started, in_progress, completed, expired';
|
||||
COMMENT ON COLUMN academy_enrollments.progress IS 'Completion percentage (0-100)';
|
||||
COMMENT ON COLUMN academy_enrollments.certificate_id IS 'Reference to issued certificate (if completed)';
|
||||
COMMENT ON COLUMN academy_enrollments.deadline IS 'Deadline by which the course must be completed';
|
||||
|
||||
-- ============================================================================
|
||||
-- 5. academy_certificates - Certificates issued upon course completion
|
||||
-- ============================================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS academy_certificates (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
tenant_id VARCHAR(255) NOT NULL,
|
||||
enrollment_id UUID NOT NULL UNIQUE REFERENCES academy_enrollments(id) ON DELETE CASCADE,
|
||||
course_id UUID NOT NULL REFERENCES academy_courses(id) ON DELETE CASCADE,
|
||||
user_id VARCHAR(255) NOT NULL,
|
||||
user_name VARCHAR(255),
|
||||
course_name VARCHAR(255),
|
||||
score INTEGER,
|
||||
issued_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
valid_until TIMESTAMP WITH TIME ZONE,
|
||||
pdf_url VARCHAR(500)
|
||||
);
|
||||
|
||||
-- Indexes for academy_certificates
|
||||
CREATE INDEX IF NOT EXISTS idx_academy_certificates_tenant ON academy_certificates(tenant_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_academy_certificates_user ON academy_certificates(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_academy_certificates_course ON academy_certificates(course_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_academy_certificates_enrollment ON academy_certificates(enrollment_id);
|
||||
|
||||
-- Comments for academy_certificates
|
||||
COMMENT ON TABLE academy_certificates IS 'Certificates issued when a user completes a course';
|
||||
COMMENT ON COLUMN academy_certificates.tenant_id IS 'Identifier for the tenant';
|
||||
COMMENT ON COLUMN academy_certificates.enrollment_id IS 'Unique reference to the enrollment (one certificate per enrollment)';
|
||||
COMMENT ON COLUMN academy_certificates.course_id IS 'Foreign key to the completed course';
|
||||
COMMENT ON COLUMN academy_certificates.user_id IS 'Identifier of the certified user';
|
||||
COMMENT ON COLUMN academy_certificates.user_name IS 'Name of the user as printed on the certificate';
|
||||
COMMENT ON COLUMN academy_certificates.course_name IS 'Name of the course as printed on the certificate';
|
||||
COMMENT ON COLUMN academy_certificates.score IS 'Final quiz score achieved (0-100)';
|
||||
COMMENT ON COLUMN academy_certificates.issued_at IS 'Timestamp when the certificate was issued';
|
||||
COMMENT ON COLUMN academy_certificates.valid_until IS 'Expiry date of the certificate (NULL = no expiry)';
|
||||
COMMENT ON COLUMN academy_certificates.pdf_url IS 'URL to the generated certificate PDF';
|
||||
|
||||
-- ============================================================================
|
||||
-- 6. academy_lesson_progress - Per-lesson progress tracking
|
||||
-- ============================================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS academy_lesson_progress (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
enrollment_id UUID NOT NULL REFERENCES academy_enrollments(id) ON DELETE CASCADE,
|
||||
lesson_id UUID NOT NULL REFERENCES academy_lessons(id) ON DELETE CASCADE,
|
||||
completed BOOLEAN DEFAULT false,
|
||||
quiz_score INTEGER,
|
||||
completed_at TIMESTAMP WITH TIME ZONE,
|
||||
CONSTRAINT uq_academy_lesson_progress_enrollment_lesson UNIQUE (enrollment_id, lesson_id)
|
||||
);
|
||||
|
||||
-- Indexes for academy_lesson_progress
|
||||
CREATE INDEX IF NOT EXISTS idx_academy_lesson_progress_enrollment ON academy_lesson_progress(enrollment_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_academy_lesson_progress_lesson ON academy_lesson_progress(lesson_id);
|
||||
|
||||
-- Comments for academy_lesson_progress
|
||||
COMMENT ON TABLE academy_lesson_progress IS 'Tracks completion status and quiz scores per lesson per enrollment';
|
||||
COMMENT ON COLUMN academy_lesson_progress.enrollment_id IS 'Foreign key to the enrollment';
|
||||
COMMENT ON COLUMN academy_lesson_progress.lesson_id IS 'Foreign key to the lesson';
|
||||
COMMENT ON COLUMN academy_lesson_progress.completed IS 'Whether the lesson has been completed';
|
||||
COMMENT ON COLUMN academy_lesson_progress.quiz_score IS 'Quiz score for this lesson (0-100), NULL if no quiz';
|
||||
COMMENT ON COLUMN academy_lesson_progress.completed_at IS 'Timestamp when the lesson was completed';
|
||||
|
||||
-- ============================================================================
|
||||
-- Helper: Upsert function for lesson progress (ON CONFLICT handling)
|
||||
-- ============================================================================
|
||||
|
||||
CREATE OR REPLACE FUNCTION upsert_academy_lesson_progress(
|
||||
p_enrollment_id UUID,
|
||||
p_lesson_id UUID,
|
||||
p_completed BOOLEAN,
|
||||
p_quiz_score INTEGER DEFAULT NULL
|
||||
)
|
||||
RETURNS academy_lesson_progress AS $$
|
||||
DECLARE
|
||||
result academy_lesson_progress;
|
||||
BEGIN
|
||||
INSERT INTO academy_lesson_progress (enrollment_id, lesson_id, completed, quiz_score, completed_at)
|
||||
VALUES (
|
||||
p_enrollment_id,
|
||||
p_lesson_id,
|
||||
p_completed,
|
||||
p_quiz_score,
|
||||
CASE WHEN p_completed THEN NOW() ELSE NULL END
|
||||
)
|
||||
ON CONFLICT ON CONSTRAINT uq_academy_lesson_progress_enrollment_lesson
|
||||
DO UPDATE SET
|
||||
completed = EXCLUDED.completed,
|
||||
quiz_score = COALESCE(EXCLUDED.quiz_score, academy_lesson_progress.quiz_score),
|
||||
completed_at = CASE
|
||||
WHEN EXCLUDED.completed AND academy_lesson_progress.completed_at IS NULL THEN NOW()
|
||||
WHEN NOT EXCLUDED.completed THEN NULL
|
||||
ELSE academy_lesson_progress.completed_at
|
||||
END
|
||||
RETURNING * INTO result;
|
||||
|
||||
RETURN result;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
COMMENT ON FUNCTION upsert_academy_lesson_progress IS 'Insert or update lesson progress with ON CONFLICT handling on the unique (enrollment_id, lesson_id) constraint';
|
||||
|
||||
-- ============================================================================
|
||||
-- Helper: Cleanup function for expired certificates
|
||||
-- ============================================================================
|
||||
|
||||
CREATE OR REPLACE FUNCTION cleanup_expired_academy_certificates(days_past_expiry INTEGER DEFAULT 0)
|
||||
RETURNS INTEGER AS $$
|
||||
DECLARE
|
||||
deleted_count INTEGER;
|
||||
BEGIN
|
||||
DELETE FROM academy_certificates
|
||||
WHERE valid_until IS NOT NULL
|
||||
AND valid_until < NOW() - (days_past_expiry || ' days')::INTERVAL;
|
||||
|
||||
GET DIAGNOSTICS deleted_count = ROW_COUNT;
|
||||
RETURN deleted_count;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
COMMENT ON FUNCTION cleanup_expired_academy_certificates IS 'Removes certificates that have expired beyond the specified number of days';
|
||||
Reference in New Issue
Block a user