feat(training): add Media Pipeline — TTS Audio, Presentation Video, Bulk Generation
All checks were successful
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-ai-compliance (push) Successful in 36s
CI / test-python-backend-compliance (push) Successful in 31s
CI / test-python-document-crawler (push) Successful in 23s
CI / test-python-dsms-gateway (push) Successful in 21s

Phase A: 8 new IT-Security training modules (SEC-PWD, SEC-DESK, SEC-KIAI,
SEC-BYOD, SEC-VIDEO, SEC-USB, SEC-INC, SEC-HOME) with CTM entries.
Bulk content and quiz generation endpoints for all 28 modules.

Phase B: Piper TTS service (Python/FastAPI) for local German speech synthesis.
training_media table, TTSClient in Go backend, audio generation endpoints,
AudioPlayer component in frontend. MinIO storage integration.

Phase C: FFmpeg presentation video pipeline — LLM generates slide scripts,
ImageMagick renders 1920x1080 slides, FFmpeg combines with audio to MP4.
VideoPlayer and ScriptPreview components in frontend.

New files: 15 created, 9 modified
- compliance-tts-service/ (Dockerfile, main.py, tts_engine.py, storage.py,
  slide_renderer.py, video_generator.py)
- migrations 014-016 (training engine, IT-security modules, media table)
- training package (models, store, content_generator, media, handlers)
- frontend (AudioPlayer, VideoPlayer, ScriptPreview, api, types, page)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Benjamin Boenisch
2026-02-16 21:42:33 +01:00
parent fba4c411dc
commit 375914e568
28 changed files with 7015 additions and 0 deletions

View File

@@ -28,6 +28,7 @@ import (
"github.com/breakpilot/ai-compliance-sdk/internal/workshop"
"github.com/breakpilot/ai-compliance-sdk/internal/portfolio"
"github.com/breakpilot/ai-compliance-sdk/internal/gci"
"github.com/breakpilot/ai-compliance-sdk/internal/training"
"github.com/gin-contrib/cors"
"github.com/gin-gonic/gin"
"github.com/jackc/pgx/v5/pgxpool"
@@ -129,6 +130,11 @@ func main() {
gciEngine := gci.NewEngine()
gciHandlers := handlers.NewGCIHandlers(gciEngine)
// Initialize Training Engine
trainingStore := training.NewStore(pool)
ttsClient := training.NewTTSClient(cfg.TTSServiceURL)
contentGenerator := training.NewContentGenerator(providerRegistry, piiDetector, trainingStore, ttsClient)
trainingHandlers := handlers.NewTrainingHandlers(trainingStore, contentGenerator)
// Initialize middleware
rbacMiddleware := rbac.NewMiddleware(rbacService, policyEngine)
@@ -680,6 +686,63 @@ func main() {
gciRoutes.GET("/iso/mappings/:controlId", gciHandlers.GetISOMapping)
}
// Training Engine routes - Compliance Training Management
trainingRoutes := v1.Group("/training")
{
// Modules
trainingRoutes.GET("/modules", trainingHandlers.ListModules)
trainingRoutes.GET("/modules/:id", trainingHandlers.GetModule)
trainingRoutes.POST("/modules", trainingHandlers.CreateModule)
trainingRoutes.PUT("/modules/:id", trainingHandlers.UpdateModule)
// Training Matrix (CTM)
trainingRoutes.GET("/matrix", trainingHandlers.GetMatrix)
trainingRoutes.GET("/matrix/:role", trainingHandlers.GetMatrixForRole)
trainingRoutes.POST("/matrix", trainingHandlers.SetMatrixEntry)
trainingRoutes.DELETE("/matrix/:role/:moduleId", trainingHandlers.DeleteMatrixEntry)
// Assignments
trainingRoutes.POST("/assignments/compute", trainingHandlers.ComputeAssignments)
trainingRoutes.GET("/assignments", trainingHandlers.ListAssignments)
trainingRoutes.GET("/assignments/:id", trainingHandlers.GetAssignment)
trainingRoutes.POST("/assignments/:id/start", trainingHandlers.StartAssignment)
trainingRoutes.POST("/assignments/:id/progress", trainingHandlers.UpdateAssignmentProgress)
trainingRoutes.POST("/assignments/:id/complete", trainingHandlers.CompleteAssignment)
// Quiz
trainingRoutes.GET("/quiz/:moduleId", trainingHandlers.GetQuiz)
trainingRoutes.POST("/quiz/:moduleId/submit", trainingHandlers.SubmitQuiz)
trainingRoutes.GET("/quiz/attempts/:assignmentId", trainingHandlers.GetQuizAttempts)
// Content Generation
trainingRoutes.POST("/content/generate", trainingHandlers.GenerateContent)
trainingRoutes.POST("/content/generate-quiz", trainingHandlers.GenerateQuiz)
trainingRoutes.POST("/content/generate-all", trainingHandlers.GenerateAllContent)
trainingRoutes.POST("/content/generate-all-quiz", trainingHandlers.GenerateAllQuizzes)
trainingRoutes.GET("/content/:moduleId", trainingHandlers.GetContent)
trainingRoutes.POST("/content/:id/publish", trainingHandlers.PublishContent)
// Audio/Media
trainingRoutes.POST("/content/:moduleId/generate-audio", trainingHandlers.GenerateAudio)
trainingRoutes.GET("/media/:moduleId", trainingHandlers.GetModuleMedia)
trainingRoutes.GET("/media/:id/url", trainingHandlers.GetMediaURL)
trainingRoutes.POST("/media/:id/publish", trainingHandlers.PublishMedia)
// Video
trainingRoutes.POST("/content/:moduleId/generate-video", trainingHandlers.GenerateVideo)
trainingRoutes.POST("/content/:moduleId/preview-script", trainingHandlers.PreviewVideoScript)
// Deadlines and Escalation
trainingRoutes.GET("/deadlines", trainingHandlers.GetDeadlines)
trainingRoutes.GET("/deadlines/overdue", trainingHandlers.GetOverdueDeadlines)
trainingRoutes.POST("/escalation/check", trainingHandlers.CheckEscalation)
// Audit and Stats
trainingRoutes.GET("/audit-log", trainingHandlers.GetAuditLog)
trainingRoutes.GET("/stats", trainingHandlers.GetStats)
trainingRoutes.GET("/certificates/:id/verify", trainingHandlers.VerifyCertificate)
}
}
// Create HTTP server

File diff suppressed because it is too large Load Diff

View File

@@ -59,6 +59,8 @@ type Config struct {
// Frontend URLs
AdminFrontendURL string
// TTS Service
TTSServiceURL string
}
// Load loads configuration from environment variables
@@ -105,6 +107,7 @@ func Load() (*Config, error) {
// Integration
ConsentServiceURL: getEnv("CONSENT_SERVICE_URL", "http://localhost:8081"),
AdminFrontendURL: getEnv("ADMIN_FRONTEND_URL", "http://localhost:3002"),
TTSServiceURL: getEnv("TTS_SERVICE_URL", "http://compliance-tts-service:8095"),
}
// Parse allowed origins

View File

@@ -0,0 +1,183 @@
package training
import (
"context"
"time"
"github.com/google/uuid"
)
// timeNow is a package-level function for testing
var timeNow = time.Now
// ComputeAssignments calculates all necessary assignments for a user based on
// their roles, existing assignments, and deadlines. Returns new assignments
// that need to be created.
func ComputeAssignments(ctx context.Context, store *Store, tenantID uuid.UUID,
userID uuid.UUID, userName, userEmail string, roleCodes []string, trigger string) ([]TrainingAssignment, error) {
if trigger == "" {
trigger = string(TriggerManual)
}
// Get all required modules for the user's roles
requiredModules, err := ComputeRequiredModules(ctx, store, tenantID, roleCodes)
if err != nil {
return nil, err
}
// Get existing active assignments for this user
existingAssignments, _, err := store.ListAssignments(ctx, tenantID, &AssignmentFilters{
UserID: &userID,
Limit: 1000,
})
if err != nil {
return nil, err
}
// Build a map of existing assignments by module_id for quick lookup
existingByModule := make(map[uuid.UUID]*TrainingAssignment)
for i := range existingAssignments {
a := &existingAssignments[i]
// Only consider non-expired, non-completed-and-expired assignments
if a.Status != AssignmentStatusExpired {
existingByModule[a.ModuleID] = a
}
}
var newAssignments []TrainingAssignment
now := timeNow().UTC()
for _, module := range requiredModules {
existing, hasExisting := existingByModule[module.ID]
// Skip if there's an active, valid assignment
if hasExisting {
switch existing.Status {
case AssignmentStatusCompleted:
// Check if the completed assignment is still valid
if existing.CompletedAt != nil {
validUntil := existing.CompletedAt.AddDate(0, 0, module.ValidityDays)
if validUntil.After(now) {
continue // Still valid, skip
}
}
case AssignmentStatusPending, AssignmentStatusInProgress:
continue // Assignment exists and is active
case AssignmentStatusOverdue:
continue // Already tracked as overdue
}
}
// Determine the role code for this assignment
roleCode := ""
for _, role := range roleCodes {
entries, err := store.GetMatrixForRole(ctx, tenantID, role)
if err != nil {
return nil, err
}
for _, entry := range entries {
if entry.ModuleID == module.ID {
roleCode = role
break
}
}
if roleCode != "" {
break
}
}
// Calculate deadline based on frequency
var deadline time.Time
switch module.FrequencyType {
case FrequencyOnboarding:
deadline = now.AddDate(0, 0, 30) // 30 days for onboarding
case FrequencyMicro:
deadline = now.AddDate(0, 0, 14) // 14 days for micro
default:
deadline = now.AddDate(0, 0, 90) // 90 days default
}
assignment := TrainingAssignment{
TenantID: tenantID,
ModuleID: module.ID,
UserID: userID,
UserName: userName,
UserEmail: userEmail,
RoleCode: roleCode,
TriggerType: TriggerType(trigger),
Status: AssignmentStatusPending,
Deadline: deadline,
ModuleCode: module.ModuleCode,
ModuleTitle: module.Title,
}
// Create the assignment in the store
if err := store.CreateAssignment(ctx, &assignment); err != nil {
return nil, err
}
// Log the assignment
store.LogAction(ctx, &AuditLogEntry{
TenantID: tenantID,
UserID: &userID,
Action: AuditActionAssigned,
EntityType: AuditEntityAssignment,
EntityID: &assignment.ID,
Details: map[string]interface{}{
"module_code": module.ModuleCode,
"trigger": trigger,
"role_code": roleCode,
"deadline": deadline.Format(time.RFC3339),
},
})
newAssignments = append(newAssignments, assignment)
}
if newAssignments == nil {
newAssignments = []TrainingAssignment{}
}
return newAssignments, nil
}
// BulkAssign assigns a module to all users with specific roles
// Returns the number of assignments created
func BulkAssign(ctx context.Context, store *Store, tenantID uuid.UUID,
moduleID uuid.UUID, users []UserInfo, trigger string, deadline time.Time) (int, error) {
if trigger == "" {
trigger = string(TriggerManual)
}
count := 0
for _, user := range users {
assignment := TrainingAssignment{
TenantID: tenantID,
ModuleID: moduleID,
UserID: user.UserID,
UserName: user.UserName,
UserEmail: user.UserEmail,
RoleCode: user.RoleCode,
TriggerType: TriggerType(trigger),
Status: AssignmentStatusPending,
Deadline: deadline,
}
if err := store.CreateAssignment(ctx, &assignment); err != nil {
return count, err
}
count++
}
return count, nil
}
// UserInfo contains basic user information for bulk operations
type UserInfo struct {
UserID uuid.UUID `json:"user_id"`
UserName string `json:"user_name"`
UserEmail string `json:"user_email"`
RoleCode string `json:"role_code"`
}

View File

@@ -0,0 +1,602 @@
package training
import (
"context"
"encoding/json"
"fmt"
"strings"
"github.com/breakpilot/ai-compliance-sdk/internal/llm"
"github.com/google/uuid"
)
// ContentGenerator generates training content and quiz questions via LLM
type ContentGenerator struct {
registry *llm.ProviderRegistry
piiDetector *llm.PIIDetector
store *Store
ttsClient *TTSClient
}
// NewContentGenerator creates a new content generator
func NewContentGenerator(registry *llm.ProviderRegistry, piiDetector *llm.PIIDetector, store *Store, ttsClient *TTSClient) *ContentGenerator {
return &ContentGenerator{
registry: registry,
piiDetector: piiDetector,
store: store,
ttsClient: ttsClient,
}
}
// GenerateModuleContent generates training content for a module via LLM
func (g *ContentGenerator) GenerateModuleContent(ctx context.Context, module TrainingModule, language string) (*ModuleContent, error) {
if language == "" {
language = "de"
}
prompt := buildContentPrompt(module, language)
resp, err := g.registry.Chat(ctx, &llm.ChatRequest{
Messages: []llm.Message{
{Role: "system", Content: getContentSystemPrompt(language)},
{Role: "user", Content: prompt},
},
Temperature: 0.15,
MaxTokens: 4096,
})
if err != nil {
return nil, fmt.Errorf("LLM content generation failed: %w", err)
}
contentBody := resp.Message.Content
// PII check on generated content
if g.piiDetector != nil && g.piiDetector.ContainsPII(contentBody) {
findings := g.piiDetector.FindPII(contentBody)
for _, f := range findings {
contentBody = strings.ReplaceAll(contentBody, f.Match, "[REDACTED]")
}
}
// Create summary (first 200 chars)
summary := contentBody
if len(summary) > 200 {
summary = summary[:200] + "..."
}
content := &ModuleContent{
ModuleID: module.ID,
ContentFormat: ContentFormatMarkdown,
ContentBody: contentBody,
Summary: summary,
GeneratedBy: "llm_" + resp.Provider,
LLMModel: resp.Model,
IsPublished: false,
}
if err := g.store.CreateModuleContent(ctx, content); err != nil {
return nil, fmt.Errorf("failed to save content: %w", err)
}
// Audit log
g.store.LogAction(ctx, &AuditLogEntry{
TenantID: module.TenantID,
Action: AuditActionContentGenerated,
EntityType: AuditEntityModule,
EntityID: &module.ID,
Details: map[string]interface{}{
"module_code": module.ModuleCode,
"provider": resp.Provider,
"model": resp.Model,
"content_id": content.ID.String(),
"version": content.Version,
"tokens_used": resp.Usage.TotalTokens,
},
})
return content, nil
}
// GenerateQuizQuestions generates quiz questions for a module based on its content
func (g *ContentGenerator) GenerateQuizQuestions(ctx context.Context, module TrainingModule, count int) ([]QuizQuestion, error) {
if count <= 0 {
count = 5
}
// Get the published content for context
content, err := g.store.GetPublishedContent(ctx, module.ID)
if err != nil {
return nil, err
}
contentContext := ""
if content != nil {
contentContext = content.ContentBody
}
prompt := buildQuizPrompt(module, contentContext, count)
resp, err := g.registry.Chat(ctx, &llm.ChatRequest{
Messages: []llm.Message{
{Role: "system", Content: getQuizSystemPrompt()},
{Role: "user", Content: prompt},
},
Temperature: 0.2,
MaxTokens: 4096,
})
if err != nil {
return nil, fmt.Errorf("LLM quiz generation failed: %w", err)
}
// Parse the JSON response
questions, err := parseQuizResponse(resp.Message.Content, module.ID)
if err != nil {
return nil, fmt.Errorf("failed to parse quiz response: %w", err)
}
// Save questions to store
for i := range questions {
questions[i].SortOrder = i + 1
if err := g.store.CreateQuizQuestion(ctx, &questions[i]); err != nil {
return nil, fmt.Errorf("failed to save question %d: %w", i+1, err)
}
}
return questions, nil
}
// ============================================================================
// Prompt Templates
// ============================================================================
func getContentSystemPrompt(language string) string {
if language == "en" {
return "You are a compliance training content expert. Generate professional, accurate training material in Markdown format. Focus on practical relevance and legal accuracy. Do not include any personal data or fictional names."
}
return "Du bist ein Experte fuer Compliance-Schulungsinhalte. Erstelle professionelle, praezise Schulungsmaterialien im Markdown-Format. Fokussiere dich auf praktische Relevanz und rechtliche Genauigkeit. Verwende keine personenbezogenen Daten oder fiktiven Namen."
}
func getQuizSystemPrompt() string {
return `Du bist ein Experte fuer Compliance-Pruefungsfragen. Erstelle Multiple-Choice-Fragen als JSON-Array.
Jede Frage hat genau 4 Antwortoptionen, davon genau eine richtige.
Antworte NUR mit dem JSON-Array, ohne zusaetzlichen Text.
Format:
[
{
"question": "Frage hier?",
"options": ["Option A", "Option B", "Option C", "Option D"],
"correct_index": 0,
"explanation": "Erklaerung warum Option A richtig ist.",
"difficulty": "medium"
}
]`
}
func buildContentPrompt(module TrainingModule, language string) string {
regulationLabels := map[RegulationArea]string{
RegulationDSGVO: "Datenschutz-Grundverordnung (DSGVO)",
RegulationNIS2: "NIS-2-Richtlinie",
RegulationISO27001: "ISO 27001 / ISMS",
RegulationAIAct: "EU AI Act / KI-Verordnung",
RegulationGeschGehG: "Geschaeftsgeheimnisgesetz (GeschGehG)",
RegulationHinSchG: "Hinweisgeberschutzgesetz (HinSchG)",
}
regulation := regulationLabels[module.RegulationArea]
if regulation == "" {
regulation = string(module.RegulationArea)
}
return fmt.Sprintf(`Erstelle Schulungsmaterial fuer folgendes Compliance-Modul:
**Modulcode:** %s
**Titel:** %s
**Beschreibung:** %s
**Regulierungsbereich:** %s
**Dauer:** %d Minuten
**NIS2-relevant:** %v
Das Material soll:
1. Eine kurze Einfuehrung in das Thema geben
2. Die wichtigsten rechtlichen Grundlagen erklaeren
3. Praktische Handlungsanweisungen fuer den Arbeitsalltag enthalten
4. Typische Fehler und Risiken aufzeigen
5. Eine Zusammenfassung der Kernpunkte bieten
Verwende klare, verstaendliche Sprache. Zielgruppe sind Mitarbeiter in Unternehmen (50-1.500 MA).
Formatiere den Inhalt als Markdown mit Ueberschriften, Aufzaehlungen und Hervorhebungen.`,
module.ModuleCode, module.Title, module.Description,
regulation, module.DurationMinutes, module.NIS2Relevant)
}
func buildQuizPrompt(module TrainingModule, contentContext string, count int) string {
prompt := fmt.Sprintf(`Erstelle %d Multiple-Choice-Pruefungsfragen fuer das Compliance-Modul:
**Modulcode:** %s
**Titel:** %s
**Regulierungsbereich:** %s`, count, module.ModuleCode, module.Title, string(module.RegulationArea))
if contentContext != "" {
// Truncate content to avoid token limit
if len(contentContext) > 3000 {
contentContext = contentContext[:3000] + "..."
}
prompt += fmt.Sprintf(`
**Schulungsinhalt als Kontext:**
%s`, contentContext)
}
prompt += fmt.Sprintf(`
Erstelle genau %d Fragen mit je 4 Antwortoptionen.
Verteile die Schwierigkeitsgrade: easy, medium, hard.
Antworte NUR mit dem JSON-Array.`, count)
return prompt
}
// parseQuizResponse parses LLM JSON response into QuizQuestion structs
func parseQuizResponse(response string, moduleID uuid.UUID) ([]QuizQuestion, error) {
// Try to extract JSON from the response (LLM might add text around it)
jsonStr := response
start := strings.Index(response, "[")
end := strings.LastIndex(response, "]")
if start >= 0 && end > start {
jsonStr = response[start : end+1]
}
type rawQuestion struct {
Question string `json:"question"`
Options []string `json:"options"`
CorrectIndex int `json:"correct_index"`
Explanation string `json:"explanation"`
Difficulty string `json:"difficulty"`
}
var rawQuestions []rawQuestion
if err := json.Unmarshal([]byte(jsonStr), &rawQuestions); err != nil {
return nil, fmt.Errorf("invalid JSON from LLM: %w", err)
}
var questions []QuizQuestion
for _, rq := range rawQuestions {
difficulty := Difficulty(rq.Difficulty)
if difficulty != DifficultyEasy && difficulty != DifficultyMedium && difficulty != DifficultyHard {
difficulty = DifficultyMedium
}
q := QuizQuestion{
ModuleID: moduleID,
Question: rq.Question,
Options: rq.Options,
CorrectIndex: rq.CorrectIndex,
Explanation: rq.Explanation,
Difficulty: difficulty,
IsActive: true,
}
if len(q.Options) != 4 {
continue // Skip malformed questions
}
if q.CorrectIndex < 0 || q.CorrectIndex >= len(q.Options) {
continue
}
questions = append(questions, q)
}
if questions == nil {
questions = []QuizQuestion{}
}
return questions, nil
}
// GenerateAllModuleContent generates text content for all modules that don't have published content yet
func (g *ContentGenerator) GenerateAllModuleContent(ctx context.Context, tenantID uuid.UUID, language string) (*BulkResult, error) {
if language == "" {
language = "de"
}
modules, _, err := g.store.ListModules(ctx, tenantID, &ModuleFilters{Limit: 100})
if err != nil {
return nil, fmt.Errorf("failed to list modules: %w", err)
}
result := &BulkResult{}
for _, module := range modules {
// Check if module already has published content
content, _ := g.store.GetPublishedContent(ctx, module.ID)
if content != nil {
result.Skipped++
continue
}
_, err := g.GenerateModuleContent(ctx, module, language)
if err != nil {
result.Errors = append(result.Errors, fmt.Sprintf("%s: %v", module.ModuleCode, err))
continue
}
result.Generated++
}
return result, nil
}
// GenerateAllQuizQuestions generates quiz questions for all modules that don't have questions yet
func (g *ContentGenerator) GenerateAllQuizQuestions(ctx context.Context, tenantID uuid.UUID, count int) (*BulkResult, error) {
if count <= 0 {
count = 5
}
modules, _, err := g.store.ListModules(ctx, tenantID, &ModuleFilters{Limit: 100})
if err != nil {
return nil, fmt.Errorf("failed to list modules: %w", err)
}
result := &BulkResult{}
for _, module := range modules {
// Check if module already has quiz questions
questions, _ := g.store.ListQuizQuestions(ctx, module.ID)
if len(questions) > 0 {
result.Skipped++
continue
}
_, err := g.GenerateQuizQuestions(ctx, module, count)
if err != nil {
result.Errors = append(result.Errors, fmt.Sprintf("%s: %v", module.ModuleCode, err))
continue
}
result.Generated++
}
return result, nil
}
// GenerateAudio generates audio for a module using the TTS service
func (g *ContentGenerator) GenerateAudio(ctx context.Context, module TrainingModule) (*TrainingMedia, error) {
// Get published content
content, err := g.store.GetPublishedContent(ctx, module.ID)
if err != nil {
return nil, fmt.Errorf("failed to get content: %w", err)
}
if content == nil {
return nil, fmt.Errorf("no published content for module %s", module.ModuleCode)
}
if g.ttsClient == nil {
return nil, fmt.Errorf("TTS client not configured")
}
// Create media record (processing)
media := &TrainingMedia{
ModuleID: module.ID,
ContentID: &content.ID,
MediaType: MediaTypeAudio,
Status: MediaStatusProcessing,
Bucket: "compliance-training-audio",
ObjectKey: fmt.Sprintf("audio/%s/%s.mp3", module.ID.String(), content.ID.String()),
MimeType: "audio/mpeg",
VoiceModel: "de_DE-thorsten-high",
Language: "de",
GeneratedBy: "tts_piper",
}
if err := g.store.CreateMedia(ctx, media); err != nil {
return nil, fmt.Errorf("failed to create media record: %w", err)
}
// Call TTS service
ttsResp, err := g.ttsClient.Synthesize(ctx, &TTSSynthesizeRequest{
Text: content.ContentBody,
Language: "de",
Voice: "thorsten-high",
ModuleID: module.ID.String(),
ContentID: content.ID.String(),
})
if err != nil {
g.store.UpdateMediaStatus(ctx, media.ID, MediaStatusFailed, 0, 0, err.Error())
return nil, fmt.Errorf("TTS synthesis failed: %w", err)
}
// Update media record
media.Status = MediaStatusCompleted
media.FileSizeBytes = ttsResp.SizeBytes
media.DurationSeconds = ttsResp.DurationSeconds
media.ObjectKey = ttsResp.ObjectKey
media.Bucket = ttsResp.Bucket
g.store.UpdateMediaStatus(ctx, media.ID, MediaStatusCompleted, ttsResp.SizeBytes, ttsResp.DurationSeconds, "")
// Audit log
g.store.LogAction(ctx, &AuditLogEntry{
TenantID: module.TenantID,
Action: AuditAction("audio_generated"),
EntityType: AuditEntityModule,
EntityID: &module.ID,
Details: map[string]interface{}{
"module_code": module.ModuleCode,
"media_id": media.ID.String(),
"duration_seconds": ttsResp.DurationSeconds,
"size_bytes": ttsResp.SizeBytes,
},
})
return media, nil
}
// VideoScript represents a structured presentation script
type VideoScript struct {
Title string `json:"title"`
Sections []VideoScriptSection `json:"sections"`
}
// VideoScriptSection is one slide in the presentation
type VideoScriptSection struct {
Heading string `json:"heading"`
Text string `json:"text"`
BulletPoints []string `json:"bullet_points"`
}
// GenerateVideoScript generates a structured video script from module content via LLM
func (g *ContentGenerator) GenerateVideoScript(ctx context.Context, module TrainingModule) (*VideoScript, error) {
content, err := g.store.GetPublishedContent(ctx, module.ID)
if err != nil {
return nil, fmt.Errorf("failed to get content: %w", err)
}
if content == nil {
return nil, fmt.Errorf("no published content for module %s", module.ModuleCode)
}
prompt := fmt.Sprintf(`Erstelle ein strukturiertes Folien-Script fuer eine Praesentations-Video-Schulung.
**Modul:** %s — %s
**Inhalt:**
%s
Erstelle 5-8 Folien. Jede Folie hat:
- heading: Kurze Ueberschrift (max 60 Zeichen)
- text: Erklaerungstext (1-2 Saetze)
- bullet_points: 2-4 Kernpunkte
Antworte NUR mit einem JSON-Objekt in diesem Format:
{
"title": "Titel der Praesentation",
"sections": [
{
"heading": "Folienueberschrift",
"text": "Erklaerungstext fuer diese Folie.",
"bullet_points": ["Punkt 1", "Punkt 2", "Punkt 3"]
}
]
}`, module.ModuleCode, module.Title, truncateText(content.ContentBody, 3000))
resp, err := g.registry.Chat(ctx, &llm.ChatRequest{
Messages: []llm.Message{
{Role: "system", Content: "Du bist ein Experte fuer Compliance-Schulungspraesentationen. Erstelle strukturierte Folien-Scripts als JSON. Antworte NUR mit dem JSON-Objekt."},
{Role: "user", Content: prompt},
},
Temperature: 0.15,
MaxTokens: 4096,
})
if err != nil {
return nil, fmt.Errorf("LLM video script generation failed: %w", err)
}
// Parse JSON response
var script VideoScript
jsonStr := resp.Message.Content
start := strings.Index(jsonStr, "{")
end := strings.LastIndex(jsonStr, "}")
if start >= 0 && end > start {
jsonStr = jsonStr[start : end+1]
}
if err := json.Unmarshal([]byte(jsonStr), &script); err != nil {
return nil, fmt.Errorf("failed to parse video script JSON: %w", err)
}
if len(script.Sections) == 0 {
return nil, fmt.Errorf("video script has no sections")
}
return &script, nil
}
// GenerateVideo generates a presentation video for a module
func (g *ContentGenerator) GenerateVideo(ctx context.Context, module TrainingModule) (*TrainingMedia, error) {
if g.ttsClient == nil {
return nil, fmt.Errorf("TTS client not configured")
}
// Check for published audio, generate if missing
audio, _ := g.store.GetPublishedAudio(ctx, module.ID)
if audio == nil {
// Try to generate audio first
var err error
audio, err = g.GenerateAudio(ctx, module)
if err != nil {
return nil, fmt.Errorf("audio generation required but failed: %w", err)
}
// Auto-publish the audio
g.store.PublishMedia(ctx, audio.ID, true)
}
// Generate video script via LLM
script, err := g.GenerateVideoScript(ctx, module)
if err != nil {
return nil, fmt.Errorf("video script generation failed: %w", err)
}
// Create media record
media := &TrainingMedia{
ModuleID: module.ID,
MediaType: MediaTypeVideo,
Status: MediaStatusProcessing,
Bucket: "compliance-training-video",
ObjectKey: fmt.Sprintf("video/%s/presentation.mp4", module.ID.String()),
MimeType: "video/mp4",
Language: "de",
GeneratedBy: "tts_ffmpeg",
}
if err := g.store.CreateMedia(ctx, media); err != nil {
return nil, fmt.Errorf("failed to create media record: %w", err)
}
// Build script map for TTS service
scriptMap := map[string]interface{}{
"title": script.Title,
"module_code": module.ModuleCode,
"sections": script.Sections,
}
// Call TTS service video generation
videoResp, err := g.ttsClient.GenerateVideo(ctx, &TTSGenerateVideoRequest{
Script: scriptMap,
AudioObjectKey: audio.ObjectKey,
ModuleID: module.ID.String(),
})
if err != nil {
g.store.UpdateMediaStatus(ctx, media.ID, MediaStatusFailed, 0, 0, err.Error())
return nil, fmt.Errorf("video generation failed: %w", err)
}
// Update media record
media.Status = MediaStatusCompleted
media.FileSizeBytes = videoResp.SizeBytes
media.DurationSeconds = videoResp.DurationSeconds
media.ObjectKey = videoResp.ObjectKey
media.Bucket = videoResp.Bucket
g.store.UpdateMediaStatus(ctx, media.ID, MediaStatusCompleted, videoResp.SizeBytes, videoResp.DurationSeconds, "")
// Audit log
g.store.LogAction(ctx, &AuditLogEntry{
TenantID: module.TenantID,
Action: AuditAction("video_generated"),
EntityType: AuditEntityModule,
EntityID: &module.ID,
Details: map[string]interface{}{
"module_code": module.ModuleCode,
"media_id": media.ID.String(),
"duration_seconds": videoResp.DurationSeconds,
"size_bytes": videoResp.SizeBytes,
"slides": len(script.Sections),
},
})
return media, nil
}
func truncateText(text string, maxLen int) string {
if len(text) <= maxLen {
return text
}
return text[:maxLen] + "..."
}

View File

@@ -0,0 +1,177 @@
package training
import (
"context"
"math"
"github.com/google/uuid"
)
// Escalation level thresholds (days overdue)
const (
EscalationThresholdL1 = 7 // Reminder to user
EscalationThresholdL2 = 14 // Notify team lead
EscalationThresholdL3 = 30 // Notify management
EscalationThresholdL4 = 45 // Notify compliance officer
)
// EscalationLabels maps levels to human-readable labels
var EscalationLabels = map[int]string{
0: "Keine Eskalation",
1: "Erinnerung an Mitarbeiter",
2: "Benachrichtigung Teamleitung",
3: "Benachrichtigung Management",
4: "Benachrichtigung Compliance Officer",
}
// CheckEscalations checks all overdue assignments and escalates as needed
func CheckEscalations(ctx context.Context, store *Store, tenantID uuid.UUID) ([]EscalationResult, error) {
overdueAssignments, err := store.ListOverdueAssignments(ctx, tenantID)
if err != nil {
return nil, err
}
var results []EscalationResult
now := timeNow().UTC()
for _, assignment := range overdueAssignments {
daysOverdue := int(math.Floor(now.Sub(assignment.Deadline).Hours() / 24))
if daysOverdue < 0 {
continue
}
// Determine new escalation level
newLevel := 0
if daysOverdue >= EscalationThresholdL4 {
newLevel = 4
} else if daysOverdue >= EscalationThresholdL3 {
newLevel = 3
} else if daysOverdue >= EscalationThresholdL2 {
newLevel = 2
} else if daysOverdue >= EscalationThresholdL1 {
newLevel = 1
}
// Only escalate if the level has increased
if newLevel <= assignment.EscalationLevel {
continue
}
previousLevel := assignment.EscalationLevel
// Update the assignment
nowTime := now
_, err := store.pool.Exec(ctx, `
UPDATE training_assignments SET
escalation_level = $2,
last_escalation_at = $3,
status = 'overdue',
updated_at = $3
WHERE id = $1
`, assignment.ID, newLevel, nowTime)
if err != nil {
return nil, err
}
// Log the escalation
assignmentID := assignment.ID
store.LogAction(ctx, &AuditLogEntry{
TenantID: tenantID,
UserID: &assignment.UserID,
Action: AuditActionEscalated,
EntityType: AuditEntityAssignment,
EntityID: &assignmentID,
Details: map[string]interface{}{
"previous_level": previousLevel,
"new_level": newLevel,
"days_overdue": daysOverdue,
"label": EscalationLabels[newLevel],
},
})
results = append(results, EscalationResult{
AssignmentID: assignment.ID,
UserID: assignment.UserID,
UserName: assignment.UserName,
UserEmail: assignment.UserEmail,
ModuleTitle: assignment.ModuleTitle,
PreviousLevel: previousLevel,
NewLevel: newLevel,
DaysOverdue: daysOverdue,
EscalationLabel: EscalationLabels[newLevel],
})
}
if results == nil {
results = []EscalationResult{}
}
return results, nil
}
// GetOverdueDeadlines returns all overdue assignments with deadline info
func GetOverdueDeadlines(ctx context.Context, store *Store, tenantID uuid.UUID) ([]DeadlineInfo, error) {
rows, err := store.pool.Query(ctx, `
SELECT
ta.id, m.module_code, m.title,
ta.user_id, ta.user_name, ta.deadline, ta.status,
EXTRACT(DAY FROM (NOW() - ta.deadline))::INT AS days_overdue
FROM training_assignments ta
JOIN training_modules m ON m.id = ta.module_id
WHERE ta.tenant_id = $1
AND ta.status IN ('pending', 'in_progress', 'overdue')
AND ta.deadline < NOW()
ORDER BY ta.deadline ASC
`, tenantID)
if err != nil {
return nil, err
}
defer rows.Close()
var deadlines []DeadlineInfo
for rows.Next() {
var d DeadlineInfo
var status string
err := rows.Scan(
&d.AssignmentID, &d.ModuleCode, &d.ModuleTitle,
&d.UserID, &d.UserName, &d.Deadline, &status,
&d.DaysLeft,
)
if err != nil {
return nil, err
}
d.Status = AssignmentStatus(status)
d.DaysLeft = -d.DaysLeft // Negative means overdue
deadlines = append(deadlines, d)
}
if deadlines == nil {
deadlines = []DeadlineInfo{}
}
return deadlines, nil
}
// VerifyCertificate verifies a certificate by checking the assignment status
func VerifyCertificate(ctx context.Context, store *Store, certificateID uuid.UUID) (bool, *TrainingAssignment, error) {
// Find assignment with this certificate
var assignmentID uuid.UUID
err := store.pool.QueryRow(ctx,
"SELECT id FROM training_assignments WHERE certificate_id = $1",
certificateID).Scan(&assignmentID)
if err != nil {
return false, nil, err
}
assignment, err := store.GetAssignment(ctx, assignmentID)
if err != nil {
return false, nil, err
}
if assignment == nil {
return false, nil, nil
}
return assignment.Status == AssignmentStatusCompleted, assignment, nil
}

View File

@@ -0,0 +1,127 @@
package training
import (
"context"
"github.com/google/uuid"
)
// ComputeRequiredModules returns all required training modules for a user
// based on their assigned roles. Deduplicates modules across roles.
func ComputeRequiredModules(ctx context.Context, store *Store, tenantID uuid.UUID, roleCodes []string) ([]TrainingModule, error) {
seen := make(map[uuid.UUID]bool)
var modules []TrainingModule
for _, role := range roleCodes {
entries, err := store.GetMatrixForRole(ctx, tenantID, role)
if err != nil {
return nil, err
}
for _, entry := range entries {
if seen[entry.ModuleID] {
continue
}
seen[entry.ModuleID] = true
module, err := store.GetModule(ctx, entry.ModuleID)
if err != nil {
return nil, err
}
if module != nil && module.IsActive {
modules = append(modules, *module)
}
}
}
if modules == nil {
modules = []TrainingModule{}
}
return modules, nil
}
// GetComplianceGaps finds modules that are required but not completed for a user
func GetComplianceGaps(ctx context.Context, store *Store, tenantID uuid.UUID, userID uuid.UUID, roleCodes []string) ([]ComplianceGap, error) {
var gaps []ComplianceGap
for _, role := range roleCodes {
entries, err := store.GetMatrixForRole(ctx, tenantID, role)
if err != nil {
return nil, err
}
for _, entry := range entries {
// Check if there's an active, completed assignment for this module
assignments, _, err := store.ListAssignments(ctx, tenantID, &AssignmentFilters{
ModuleID: &entry.ModuleID,
UserID: &userID,
Limit: 1,
})
if err != nil {
return nil, err
}
gap := ComplianceGap{
ModuleID: entry.ModuleID,
ModuleCode: entry.ModuleCode,
ModuleTitle: entry.ModuleTitle,
RoleCode: role,
IsMandatory: entry.IsMandatory,
}
// Determine regulation area from module
module, err := store.GetModule(ctx, entry.ModuleID)
if err != nil {
return nil, err
}
if module != nil {
gap.RegulationArea = module.RegulationArea
}
if len(assignments) == 0 {
gap.Status = "missing"
gaps = append(gaps, gap)
} else {
a := assignments[0]
gap.AssignmentID = &a.ID
gap.Deadline = &a.Deadline
switch a.Status {
case AssignmentStatusCompleted:
// No gap
continue
case AssignmentStatusOverdue, AssignmentStatusExpired:
gap.Status = string(a.Status)
gaps = append(gaps, gap)
default:
// Check if overdue
if a.Deadline.Before(timeNow()) {
gap.Status = "overdue"
gaps = append(gaps, gap)
}
}
}
}
}
if gaps == nil {
gaps = []ComplianceGap{}
}
return gaps, nil
}
// BuildMatrixResponse builds the full CTM response grouped by role
func BuildMatrixResponse(entries []TrainingMatrixEntry) *MatrixResponse {
resp := &MatrixResponse{
Entries: make(map[string][]TrainingMatrixEntry),
Roles: RoleLabels,
}
for _, entry := range entries {
resp.Entries[entry.RoleCode] = append(resp.Entries[entry.RoleCode], entry)
}
return resp
}

View File

@@ -0,0 +1,186 @@
package training
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
"github.com/google/uuid"
)
// MediaType represents audio or video
type MediaType string
const (
MediaTypeAudio MediaType = "audio"
MediaTypeVideo MediaType = "video"
)
// MediaStatus represents the processing status
type MediaStatus string
const (
MediaStatusProcessing MediaStatus = "processing"
MediaStatusCompleted MediaStatus = "completed"
MediaStatusFailed MediaStatus = "failed"
)
// TrainingMedia represents a generated media file
type TrainingMedia struct {
ID uuid.UUID `json:"id"`
ModuleID uuid.UUID `json:"module_id"`
ContentID *uuid.UUID `json:"content_id,omitempty"`
MediaType MediaType `json:"media_type"`
Status MediaStatus `json:"status"`
Bucket string `json:"bucket"`
ObjectKey string `json:"object_key"`
FileSizeBytes int64 `json:"file_size_bytes"`
DurationSeconds float64 `json:"duration_seconds"`
MimeType string `json:"mime_type"`
VoiceModel string `json:"voice_model"`
Language string `json:"language"`
Metadata json.RawMessage `json:"metadata"`
ErrorMessage string `json:"error_message,omitempty"`
GeneratedBy string `json:"generated_by"`
IsPublished bool `json:"is_published"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// ============================================================================
// TTS Client
// ============================================================================
// TTSClient communicates with the compliance-tts-service
type TTSClient struct {
baseURL string
httpClient *http.Client
}
// NewTTSClient creates a new TTS service client
func NewTTSClient(baseURL string) *TTSClient {
return &TTSClient{
baseURL: baseURL,
httpClient: &http.Client{
Timeout: 5 * time.Minute,
},
}
}
// TTSSynthesizeRequest is the request to synthesize audio
type TTSSynthesizeRequest struct {
Text string `json:"text"`
Language string `json:"language"`
Voice string `json:"voice"`
ModuleID string `json:"module_id"`
ContentID string `json:"content_id,omitempty"`
}
// TTSSynthesizeResponse is the response from audio synthesis
type TTSSynthesizeResponse struct {
AudioID string `json:"audio_id"`
Bucket string `json:"bucket"`
ObjectKey string `json:"object_key"`
DurationSeconds float64 `json:"duration_seconds"`
SizeBytes int64 `json:"size_bytes"`
}
// TTSGenerateVideoRequest is the request to generate a video
type TTSGenerateVideoRequest struct {
Script map[string]interface{} `json:"script"`
AudioObjectKey string `json:"audio_object_key"`
ModuleID string `json:"module_id"`
}
// TTSGenerateVideoResponse is the response from video generation
type TTSGenerateVideoResponse struct {
VideoID string `json:"video_id"`
Bucket string `json:"bucket"`
ObjectKey string `json:"object_key"`
DurationSeconds float64 `json:"duration_seconds"`
SizeBytes int64 `json:"size_bytes"`
}
// Synthesize calls the TTS service to create audio
func (c *TTSClient) Synthesize(ctx context.Context, req *TTSSynthesizeRequest) (*TTSSynthesizeResponse, error) {
body, err := json.Marshal(req)
if err != nil {
return nil, fmt.Errorf("marshal request: %w", err)
}
httpReq, err := http.NewRequestWithContext(ctx, "POST", c.baseURL+"/synthesize", bytes.NewReader(body))
if err != nil {
return nil, fmt.Errorf("create request: %w", err)
}
httpReq.Header.Set("Content-Type", "application/json")
resp, err := c.httpClient.Do(httpReq)
if err != nil {
return nil, fmt.Errorf("TTS service request failed: %w", err)
}
defer resp.Body.Close()
respBody, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("TTS service error (%d): %s", resp.StatusCode, string(respBody))
}
var result TTSSynthesizeResponse
if err := json.Unmarshal(respBody, &result); err != nil {
return nil, fmt.Errorf("parse TTS response: %w", err)
}
return &result, nil
}
// GenerateVideo calls the TTS service to create a presentation video
func (c *TTSClient) GenerateVideo(ctx context.Context, req *TTSGenerateVideoRequest) (*TTSGenerateVideoResponse, error) {
body, err := json.Marshal(req)
if err != nil {
return nil, fmt.Errorf("marshal request: %w", err)
}
httpReq, err := http.NewRequestWithContext(ctx, "POST", c.baseURL+"/generate-video", bytes.NewReader(body))
if err != nil {
return nil, fmt.Errorf("create request: %w", err)
}
httpReq.Header.Set("Content-Type", "application/json")
resp, err := c.httpClient.Do(httpReq)
if err != nil {
return nil, fmt.Errorf("TTS service request failed: %w", err)
}
defer resp.Body.Close()
respBody, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("TTS service error (%d): %s", resp.StatusCode, string(respBody))
}
var result TTSGenerateVideoResponse
if err := json.Unmarshal(respBody, &result); err != nil {
return nil, fmt.Errorf("parse TTS response: %w", err)
}
return &result, nil
}
// IsHealthy checks if the TTS service is responsive
func (c *TTSClient) IsHealthy(ctx context.Context) bool {
httpReq, err := http.NewRequestWithContext(ctx, "GET", c.baseURL+"/health", nil)
if err != nil {
return false
}
resp, err := c.httpClient.Do(httpReq)
if err != nil {
return false
}
defer resp.Body.Close()
return resp.StatusCode == http.StatusOK
}

View File

@@ -0,0 +1,500 @@
package training
import (
"time"
"github.com/google/uuid"
)
// ============================================================================
// Constants / Enums
// ============================================================================
// RegulationArea represents a compliance regulation area
type RegulationArea string
const (
RegulationDSGVO RegulationArea = "dsgvo"
RegulationNIS2 RegulationArea = "nis2"
RegulationISO27001 RegulationArea = "iso27001"
RegulationAIAct RegulationArea = "ai_act"
RegulationGeschGehG RegulationArea = "geschgehg"
RegulationHinSchG RegulationArea = "hinschg"
)
// FrequencyType represents the training frequency
type FrequencyType string
const (
FrequencyOnboarding FrequencyType = "onboarding"
FrequencyAnnual FrequencyType = "annual"
FrequencyEventTrigger FrequencyType = "event_trigger"
FrequencyMicro FrequencyType = "micro"
)
// AssignmentStatus represents the status of a training assignment
type AssignmentStatus string
const (
AssignmentStatusPending AssignmentStatus = "pending"
AssignmentStatusInProgress AssignmentStatus = "in_progress"
AssignmentStatusCompleted AssignmentStatus = "completed"
AssignmentStatusOverdue AssignmentStatus = "overdue"
AssignmentStatusExpired AssignmentStatus = "expired"
)
// TriggerType represents how a training was assigned
type TriggerType string
const (
TriggerOnboarding TriggerType = "onboarding"
TriggerAnnual TriggerType = "annual"
TriggerEvent TriggerType = "event"
TriggerManual TriggerType = "manual"
)
// ContentFormat represents the format of module content
type ContentFormat string
const (
ContentFormatMarkdown ContentFormat = "markdown"
ContentFormatHTML ContentFormat = "html"
)
// Difficulty represents the difficulty level of a quiz question
type Difficulty string
const (
DifficultyEasy Difficulty = "easy"
DifficultyMedium Difficulty = "medium"
DifficultyHard Difficulty = "hard"
)
// AuditAction represents an action in the audit trail
type AuditAction string
const (
AuditActionAssigned AuditAction = "assigned"
AuditActionStarted AuditAction = "started"
AuditActionCompleted AuditAction = "completed"
AuditActionQuizSubmitted AuditAction = "quiz_submitted"
AuditActionEscalated AuditAction = "escalated"
AuditActionCertificateIssued AuditAction = "certificate_issued"
AuditActionContentGenerated AuditAction = "content_generated"
)
// AuditEntityType represents the type of entity in audit log
type AuditEntityType string
const (
AuditEntityAssignment AuditEntityType = "assignment"
AuditEntityModule AuditEntityType = "module"
AuditEntityQuiz AuditEntityType = "quiz"
AuditEntityCertificate AuditEntityType = "certificate"
)
// ============================================================================
// Role Constants
// ============================================================================
const (
RoleR1 = "R1" // Geschaeftsfuehrung
RoleR2 = "R2" // IT-Leitung
RoleR3 = "R3" // DSB
RoleR4 = "R4" // ISB
RoleR5 = "R5" // HR
RoleR6 = "R6" // Einkauf
RoleR7 = "R7" // Fachabteilung
RoleR8 = "R8" // IT-Admin
RoleR9 = "R9" // Alle Mitarbeiter
)
// RoleLabels maps role codes to human-readable labels
var RoleLabels = map[string]string{
RoleR1: "Geschaeftsfuehrung",
RoleR2: "IT-Leitung",
RoleR3: "Datenschutzbeauftragter",
RoleR4: "Informationssicherheitsbeauftragter",
RoleR5: "HR / Personal",
RoleR6: "Einkauf / Beschaffung",
RoleR7: "Fachabteilung",
RoleR8: "IT-Administration",
RoleR9: "Alle Mitarbeiter",
}
// NIS2RoleMapping maps internal roles to NIS2 levels
var NIS2RoleMapping = map[string]string{
RoleR1: "N1", // Geschaeftsfuehrung
RoleR2: "N2", // IT-Leitung
RoleR3: "N3", // DSB
RoleR4: "N3", // ISB
RoleR5: "N4", // HR
RoleR6: "N4", // Einkauf
RoleR7: "N5", // Fachabteilung
RoleR8: "N2", // IT-Admin
RoleR9: "N5", // Alle Mitarbeiter
}
// ============================================================================
// Main Entities
// ============================================================================
// TrainingModule represents a compliance training module
type TrainingModule struct {
ID uuid.UUID `json:"id"`
TenantID uuid.UUID `json:"tenant_id"`
AcademyCourseID *uuid.UUID `json:"academy_course_id,omitempty"`
ModuleCode string `json:"module_code"`
Title string `json:"title"`
Description string `json:"description,omitempty"`
RegulationArea RegulationArea `json:"regulation_area"`
NIS2Relevant bool `json:"nis2_relevant"`
ISOControls []string `json:"iso_controls"` // JSONB
FrequencyType FrequencyType `json:"frequency_type"`
ValidityDays int `json:"validity_days"`
RiskWeight float64 `json:"risk_weight"`
ContentType string `json:"content_type"`
DurationMinutes int `json:"duration_minutes"`
PassThreshold int `json:"pass_threshold"`
IsActive bool `json:"is_active"`
SortOrder int `json:"sort_order"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// TrainingMatrixEntry represents a role-to-module mapping in the CTM
type TrainingMatrixEntry struct {
ID uuid.UUID `json:"id"`
TenantID uuid.UUID `json:"tenant_id"`
RoleCode string `json:"role_code"`
ModuleID uuid.UUID `json:"module_id"`
IsMandatory bool `json:"is_mandatory"`
Priority int `json:"priority"`
CreatedAt time.Time `json:"created_at"`
// Joined fields (optional, populated in queries)
ModuleCode string `json:"module_code,omitempty"`
ModuleTitle string `json:"module_title,omitempty"`
}
// TrainingAssignment represents a user's training assignment
type TrainingAssignment struct {
ID uuid.UUID `json:"id"`
TenantID uuid.UUID `json:"tenant_id"`
ModuleID uuid.UUID `json:"module_id"`
UserID uuid.UUID `json:"user_id"`
UserName string `json:"user_name"`
UserEmail string `json:"user_email"`
RoleCode string `json:"role_code,omitempty"`
TriggerType TriggerType `json:"trigger_type"`
TriggerEvent string `json:"trigger_event,omitempty"`
Status AssignmentStatus `json:"status"`
ProgressPercent int `json:"progress_percent"`
QuizScore *float64 `json:"quiz_score,omitempty"`
QuizPassed *bool `json:"quiz_passed,omitempty"`
QuizAttempts int `json:"quiz_attempts"`
StartedAt *time.Time `json:"started_at,omitempty"`
CompletedAt *time.Time `json:"completed_at,omitempty"`
Deadline time.Time `json:"deadline"`
CertificateID *uuid.UUID `json:"certificate_id,omitempty"`
EscalationLevel int `json:"escalation_level"`
LastEscalationAt *time.Time `json:"last_escalation_at,omitempty"`
EnrollmentID *uuid.UUID `json:"enrollment_id,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
// Joined fields
ModuleCode string `json:"module_code,omitempty"`
ModuleTitle string `json:"module_title,omitempty"`
}
// QuizQuestion represents a persistent quiz question for a module
type QuizQuestion struct {
ID uuid.UUID `json:"id"`
ModuleID uuid.UUID `json:"module_id"`
Question string `json:"question"`
Options []string `json:"options"` // JSONB
CorrectIndex int `json:"correct_index"`
Explanation string `json:"explanation,omitempty"`
Difficulty Difficulty `json:"difficulty"`
IsActive bool `json:"is_active"`
SortOrder int `json:"sort_order"`
CreatedAt time.Time `json:"created_at"`
}
// QuizAttempt represents a single quiz attempt by a user
type QuizAttempt struct {
ID uuid.UUID `json:"id"`
AssignmentID uuid.UUID `json:"assignment_id"`
UserID uuid.UUID `json:"user_id"`
Answers []QuizAnswer `json:"answers"` // JSONB
Score float64 `json:"score"`
Passed bool `json:"passed"`
CorrectCount int `json:"correct_count"`
TotalCount int `json:"total_count"`
DurationSeconds *int `json:"duration_seconds,omitempty"`
AttemptedAt time.Time `json:"attempted_at"`
}
// QuizAnswer represents a single answer within a quiz attempt
type QuizAnswer struct {
QuestionID uuid.UUID `json:"question_id"`
SelectedIndex int `json:"selected_index"`
Correct bool `json:"correct"`
}
// AuditLogEntry represents an entry in the training audit trail
type AuditLogEntry struct {
ID uuid.UUID `json:"id"`
TenantID uuid.UUID `json:"tenant_id"`
UserID *uuid.UUID `json:"user_id,omitempty"`
Action AuditAction `json:"action"`
EntityType AuditEntityType `json:"entity_type"`
EntityID *uuid.UUID `json:"entity_id,omitempty"`
Details map[string]interface{} `json:"details"` // JSONB
IPAddress string `json:"ip_address,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
// ModuleContent represents LLM-generated or manual content for a module
type ModuleContent struct {
ID uuid.UUID `json:"id"`
ModuleID uuid.UUID `json:"module_id"`
Version int `json:"version"`
ContentFormat ContentFormat `json:"content_format"`
ContentBody string `json:"content_body"`
Summary string `json:"summary,omitempty"`
GeneratedBy string `json:"generated_by,omitempty"`
LLMModel string `json:"llm_model,omitempty"`
IsPublished bool `json:"is_published"`
ReviewedBy *uuid.UUID `json:"reviewed_by,omitempty"`
ReviewedAt *time.Time `json:"reviewed_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// TrainingStats contains aggregated training metrics
type TrainingStats struct {
TotalModules int `json:"total_modules"`
TotalAssignments int `json:"total_assignments"`
CompletionRate float64 `json:"completion_rate"`
OverdueCount int `json:"overdue_count"`
PendingCount int `json:"pending_count"`
InProgressCount int `json:"in_progress_count"`
CompletedCount int `json:"completed_count"`
AvgQuizScore float64 `json:"avg_quiz_score"`
AvgCompletionDays float64 `json:"avg_completion_days"`
UpcomingDeadlines int `json:"upcoming_deadlines"` // within 7 days
}
// ComplianceGap represents a missing or overdue training requirement
type ComplianceGap struct {
ModuleID uuid.UUID `json:"module_id"`
ModuleCode string `json:"module_code"`
ModuleTitle string `json:"module_title"`
RegulationArea RegulationArea `json:"regulation_area"`
RoleCode string `json:"role_code"`
IsMandatory bool `json:"is_mandatory"`
AssignmentID *uuid.UUID `json:"assignment_id,omitempty"`
Status string `json:"status"` // "missing", "overdue", "expired"
Deadline *time.Time `json:"deadline,omitempty"`
}
// EscalationResult represents the result of an escalation check
type EscalationResult struct {
AssignmentID uuid.UUID `json:"assignment_id"`
UserID uuid.UUID `json:"user_id"`
UserName string `json:"user_name"`
UserEmail string `json:"user_email"`
ModuleTitle string `json:"module_title"`
PreviousLevel int `json:"previous_level"`
NewLevel int `json:"new_level"`
DaysOverdue int `json:"days_overdue"`
EscalationLabel string `json:"escalation_label"`
}
// DeadlineInfo represents upcoming deadline information
type DeadlineInfo struct {
AssignmentID uuid.UUID `json:"assignment_id"`
ModuleCode string `json:"module_code"`
ModuleTitle string `json:"module_title"`
UserID uuid.UUID `json:"user_id"`
UserName string `json:"user_name"`
Deadline time.Time `json:"deadline"`
DaysLeft int `json:"days_left"`
Status AssignmentStatus `json:"status"`
}
// ============================================================================
// Filter Types
// ============================================================================
// ModuleFilters defines filters for listing modules
type ModuleFilters struct {
RegulationArea RegulationArea
FrequencyType FrequencyType
IsActive *bool
NIS2Relevant *bool
Search string
Limit int
Offset int
}
// AssignmentFilters defines filters for listing assignments
type AssignmentFilters struct {
ModuleID *uuid.UUID
UserID *uuid.UUID
RoleCode string
Status AssignmentStatus
Overdue *bool
Limit int
Offset int
}
// AuditLogFilters defines filters for listing audit log entries
type AuditLogFilters struct {
UserID *uuid.UUID
Action AuditAction
EntityType AuditEntityType
Limit int
Offset int
}
// ============================================================================
// API Request/Response Types
// ============================================================================
// CreateModuleRequest is the API request for creating a training module
type CreateModuleRequest struct {
ModuleCode string `json:"module_code" binding:"required"`
Title string `json:"title" binding:"required"`
Description string `json:"description,omitempty"`
RegulationArea RegulationArea `json:"regulation_area" binding:"required"`
NIS2Relevant bool `json:"nis2_relevant"`
ISOControls []string `json:"iso_controls,omitempty"`
FrequencyType FrequencyType `json:"frequency_type" binding:"required"`
ValidityDays int `json:"validity_days"`
RiskWeight float64 `json:"risk_weight"`
ContentType string `json:"content_type"`
DurationMinutes int `json:"duration_minutes"`
PassThreshold int `json:"pass_threshold"`
}
// UpdateModuleRequest is the API request for updating a training module
type UpdateModuleRequest struct {
Title *string `json:"title,omitempty"`
Description *string `json:"description,omitempty"`
NIS2Relevant *bool `json:"nis2_relevant,omitempty"`
ISOControls []string `json:"iso_controls,omitempty"`
ValidityDays *int `json:"validity_days,omitempty"`
RiskWeight *float64 `json:"risk_weight,omitempty"`
DurationMinutes *int `json:"duration_minutes,omitempty"`
PassThreshold *int `json:"pass_threshold,omitempty"`
IsActive *bool `json:"is_active,omitempty"`
}
// SetMatrixEntryRequest is the API request for setting a CTM entry
type SetMatrixEntryRequest struct {
RoleCode string `json:"role_code" binding:"required"`
ModuleID uuid.UUID `json:"module_id" binding:"required"`
IsMandatory bool `json:"is_mandatory"`
Priority int `json:"priority"`
}
// ComputeAssignmentsRequest is the API request for computing assignments
type ComputeAssignmentsRequest struct {
UserID uuid.UUID `json:"user_id" binding:"required"`
UserName string `json:"user_name" binding:"required"`
UserEmail string `json:"user_email" binding:"required"`
Roles []string `json:"roles" binding:"required"`
Trigger string `json:"trigger"`
}
// UpdateAssignmentProgressRequest updates progress on an assignment
type UpdateAssignmentProgressRequest struct {
Progress int `json:"progress" binding:"required"`
}
// SubmitTrainingQuizRequest is the API request for submitting a quiz
type SubmitTrainingQuizRequest struct {
AssignmentID uuid.UUID `json:"assignment_id" binding:"required"`
Answers []QuizAnswer `json:"answers" binding:"required"`
DurationSeconds *int `json:"duration_seconds,omitempty"`
}
// SubmitTrainingQuizResponse is the API response for quiz submission
type SubmitTrainingQuizResponse struct {
AttemptID uuid.UUID `json:"attempt_id"`
Score float64 `json:"score"`
Passed bool `json:"passed"`
CorrectCount int `json:"correct_count"`
TotalCount int `json:"total_count"`
Threshold int `json:"threshold"`
}
// GenerateContentRequest is the API request for LLM content generation
type GenerateContentRequest struct {
ModuleID uuid.UUID `json:"module_id" binding:"required"`
Language string `json:"language"`
}
// GenerateQuizRequest is the API request for LLM quiz generation
type GenerateQuizRequest struct {
ModuleID uuid.UUID `json:"module_id" binding:"required"`
Count int `json:"count"`
}
// PublishContentRequest is the API request for publishing content
type PublishContentRequest struct {
ReviewedBy uuid.UUID `json:"reviewed_by"`
}
// BulkAssignRequest is the API request for bulk assigning a module
type BulkAssignRequest struct {
ModuleID uuid.UUID `json:"module_id" binding:"required"`
RoleCodes []string `json:"role_codes" binding:"required"`
Trigger string `json:"trigger"`
Deadline time.Time `json:"deadline" binding:"required"`
}
// ModuleListResponse is the API response for listing modules
type ModuleListResponse struct {
Modules []TrainingModule `json:"modules"`
Total int `json:"total"`
}
// AssignmentListResponse is the API response for listing assignments
type AssignmentListResponse struct {
Assignments []TrainingAssignment `json:"assignments"`
Total int `json:"total"`
}
// MatrixResponse is the API response for the full training matrix
type MatrixResponse struct {
Entries map[string][]TrainingMatrixEntry `json:"entries"` // role_code -> entries
Roles map[string]string `json:"roles"` // role_code -> label
}
// AuditLogResponse is the API response for listing audit log entries
type AuditLogResponse struct {
Entries []AuditLogEntry `json:"entries"`
Total int `json:"total"`
}
// EscalationResponse is the API response for escalation check
type EscalationResponse struct {
Results []EscalationResult `json:"results"`
TotalChecked int `json:"total_checked"`
Escalated int `json:"escalated"`
}
// DeadlineListResponse is the API response for listing deadlines
type DeadlineListResponse struct {
Deadlines []DeadlineInfo `json:"deadlines"`
Total int `json:"total"`
}
// BulkResult holds the result of a bulk generation operation
type BulkResult struct {
Generated int `json:"generated"`
Skipped int `json:"skipped"`
Errors []string `json:"errors"`
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,268 @@
-- =========================================================
-- Migration 014: Compliance Training Engine
-- =========================================================
-- Training Module Catalog, Compliance Training Matrix (CTM),
-- Assignments, Quiz Engine, Content Pipeline, Audit Trail
-- =========================================================
-- Training-Module-Katalog (erweiterte Kurs-Metadaten)
CREATE TABLE IF NOT EXISTS training_modules (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES compliance_tenants(id) ON DELETE CASCADE,
academy_course_id UUID REFERENCES academy_courses(id),
module_code VARCHAR(20) NOT NULL,
title VARCHAR(255) NOT NULL,
description TEXT,
regulation_area VARCHAR(20) NOT NULL,
nis2_relevant BOOLEAN DEFAULT FALSE,
iso_controls JSONB DEFAULT '[]',
frequency_type VARCHAR(20) NOT NULL DEFAULT 'annual',
validity_days INT DEFAULT 365,
risk_weight FLOAT DEFAULT 2.0,
content_type VARCHAR(20) DEFAULT 'text',
duration_minutes INT DEFAULT 30,
pass_threshold INT DEFAULT 70,
is_active BOOLEAN DEFAULT TRUE,
sort_order INT DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(tenant_id, module_code)
);
-- Compliance Training Matrix: welche Rollen brauchen welche Module
CREATE TABLE IF NOT EXISTS training_matrix (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES compliance_tenants(id) ON DELETE CASCADE,
role_code VARCHAR(10) NOT NULL,
module_id UUID NOT NULL REFERENCES training_modules(id) ON DELETE CASCADE,
is_mandatory BOOLEAN DEFAULT TRUE,
priority INT DEFAULT 5,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(tenant_id, role_code, module_id)
);
-- Training-Zuweisungen (automatisch oder manuell)
CREATE TABLE IF NOT EXISTS training_assignments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES compliance_tenants(id) ON DELETE CASCADE,
module_id UUID NOT NULL REFERENCES training_modules(id) ON DELETE CASCADE,
user_id UUID NOT NULL,
user_name VARCHAR(255) NOT NULL,
user_email VARCHAR(255) NOT NULL,
role_code VARCHAR(10),
trigger_type VARCHAR(20) NOT NULL,
trigger_event VARCHAR(100),
status VARCHAR(20) DEFAULT 'pending',
progress_percent INT DEFAULT 0,
quiz_score FLOAT,
quiz_passed BOOLEAN,
quiz_attempts INT DEFAULT 0,
started_at TIMESTAMPTZ,
completed_at TIMESTAMPTZ,
deadline TIMESTAMPTZ NOT NULL,
certificate_id UUID,
escalation_level INT DEFAULT 0,
last_escalation_at TIMESTAMPTZ,
enrollment_id UUID REFERENCES academy_enrollments(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Quiz-Fragenbank (persistent, nicht nur JSONB)
CREATE TABLE IF NOT EXISTS training_quiz_questions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
module_id UUID NOT NULL REFERENCES training_modules(id) ON DELETE CASCADE,
question TEXT NOT NULL,
options JSONB NOT NULL,
correct_index INT NOT NULL,
explanation TEXT,
difficulty VARCHAR(10) DEFAULT 'medium',
is_active BOOLEAN DEFAULT TRUE,
sort_order INT DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Quiz-Versuche (jeder Versuch einzeln getracked)
CREATE TABLE IF NOT EXISTS training_quiz_attempts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
assignment_id UUID NOT NULL REFERENCES training_assignments(id) ON DELETE CASCADE,
user_id UUID NOT NULL,
answers JSONB NOT NULL,
score FLOAT NOT NULL,
passed BOOLEAN NOT NULL,
correct_count INT NOT NULL,
total_count INT NOT NULL,
duration_seconds INT,
attempted_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Audit Trail fuer Training-Aktionen
CREATE TABLE IF NOT EXISTS training_audit_log (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES compliance_tenants(id) ON DELETE CASCADE,
user_id UUID,
action VARCHAR(50) NOT NULL,
entity_type VARCHAR(30) NOT NULL,
entity_id UUID,
details JSONB DEFAULT '{}',
ip_address VARCHAR(45),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Module-Inhalte (LLM-generiert oder manuell)
CREATE TABLE IF NOT EXISTS training_module_content (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
module_id UUID NOT NULL REFERENCES training_modules(id) ON DELETE CASCADE,
version INT DEFAULT 1,
content_format VARCHAR(20) DEFAULT 'markdown',
content_body TEXT NOT NULL,
summary TEXT,
generated_by VARCHAR(50),
llm_model VARCHAR(100),
is_published BOOLEAN DEFAULT FALSE,
reviewed_by UUID,
reviewed_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- =========================================================
-- INDEXES
-- =========================================================
CREATE INDEX IF NOT EXISTS idx_training_modules_tenant ON training_modules(tenant_id);
CREATE INDEX IF NOT EXISTS idx_training_modules_regulation ON training_modules(tenant_id, regulation_area);
CREATE INDEX IF NOT EXISTS idx_training_matrix_tenant_role ON training_matrix(tenant_id, role_code);
CREATE INDEX IF NOT EXISTS idx_training_matrix_module ON training_matrix(module_id);
CREATE INDEX IF NOT EXISTS idx_training_assignments_tenant ON training_assignments(tenant_id);
CREATE INDEX IF NOT EXISTS idx_training_assignments_user ON training_assignments(tenant_id, user_id);
CREATE INDEX IF NOT EXISTS idx_training_assignments_status ON training_assignments(tenant_id, status);
CREATE INDEX IF NOT EXISTS idx_training_assignments_deadline ON training_assignments(deadline)
WHERE status NOT IN ('completed', 'expired');
CREATE INDEX IF NOT EXISTS idx_training_assignments_overdue ON training_assignments(tenant_id, deadline, status)
WHERE status IN ('pending', 'in_progress') AND deadline < NOW();
CREATE INDEX IF NOT EXISTS idx_training_quiz_questions_module ON training_quiz_questions(module_id);
CREATE INDEX IF NOT EXISTS idx_training_quiz_attempts_assignment ON training_quiz_attempts(assignment_id);
CREATE INDEX IF NOT EXISTS idx_training_audit_log_tenant ON training_audit_log(tenant_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_training_module_content_module ON training_module_content(module_id, is_published);
-- =========================================================
-- SEED DATA: Template Modules (tenant_id = 00000000-...)
-- =========================================================
DO $$
DECLARE
t_id UUID := '00000000-0000-0000-0000-000000000000';
m_gdpr_bas UUID; m_gdpr_adv UUID; m_gdpr_art UUID; m_gdpr_dpia UUID;
m_nis2_m1 UUID; m_nis2_m2 UUID; m_nis2_m3 UUID; m_nis2_m4 UUID; m_nis2_m5 UUID;
m_isms_bas UUID; m_isms_int UUID; m_isms_aud UUID;
m_ai_bas UUID; m_ai_adv UUID; m_ai_risk UUID;
m_gesch_bas UUID; m_hin_bas UUID;
m_mail_sec UUID; m_soc_eng UUID; m_phish UUID;
BEGIN
-- Skip if seed data already exists
IF EXISTS (SELECT 1 FROM training_modules WHERE tenant_id = t_id LIMIT 1) THEN
RAISE NOTICE 'Seed data already exists, skipping';
RETURN;
END IF;
-- Insert modules and capture IDs
INSERT INTO training_modules (id, tenant_id, module_code, title, description, regulation_area, nis2_relevant, frequency_type, validity_days, risk_weight, duration_minutes, pass_threshold, sort_order)
VALUES
(gen_random_uuid(), t_id, 'GDPR-BAS', 'DSGVO Grundlagen', 'Grundlegende Datenschutzprinzipien, Rechtsgrundlagen, Verarbeitungsgrundsaetze', 'dsgvo', false, 'annual', 365, 2.0, 30, 70, 1),
(gen_random_uuid(), t_id, 'GDPR-ADV', 'DSGVO Vertiefung', 'Auftragsverarbeitung, Drittlandtransfer, Datenschutz-Management', 'dsgvo', false, 'annual', 365, 2.5, 45, 70, 2),
(gen_random_uuid(), t_id, 'GDPR-ART', 'Betroffenenrechte', 'Auskunft, Berichtigung, Loeschung, Einschraenkung, Datenportabilitaet', 'dsgvo', false, 'annual', 365, 2.0, 30, 70, 3),
(gen_random_uuid(), t_id, 'GDPR-DPIA', 'Datenschutz-Folgenabschaetzung', 'DSFA-Durchfuehrung, Schwellwertanalyse, Risikobewertung', 'dsgvo', false, 'event_trigger', 365, 3.0, 60, 80, 4),
(gen_random_uuid(), t_id, 'NIS2-M1', 'NIS2 Geschaeftsleitungspflicht', 'Haftung, Schulungspflicht, Risikomanagement-Governance', 'nis2', true, 'annual', 365, 3.0, 45, 80, 5),
(gen_random_uuid(), t_id, 'NIS2-M2', 'NIS2 Incident Response', 'Meldepflichten, 24h/72h-Fristen, Incident-Response-Plan', 'nis2', true, 'annual', 365, 3.0, 60, 80, 6),
(gen_random_uuid(), t_id, 'NIS2-M3', 'NIS2 Lieferkettensicherheit', 'Supply-Chain-Risiken, Dienstleisterbewertung, Vertragsklauseln', 'nis2', true, 'annual', 365, 2.5, 45, 70, 7),
(gen_random_uuid(), t_id, 'NIS2-M4', 'NIS2 BCM', 'Business Continuity, Notfallplanung, Wiederherstellung', 'nis2', true, 'annual', 365, 2.5, 45, 70, 8),
(gen_random_uuid(), t_id, 'NIS2-M5', 'NIS2 Risikomanagement', 'Risikoanalyse, Massnahmenplanung, Wirksamkeitspruefung', 'nis2', true, 'annual', 365, 3.0, 60, 80, 9),
(gen_random_uuid(), t_id, 'ISMS-BAS', 'ISMS Grundlagen', 'ISO 27001 Anforderungen, PDCA-Zyklus, Informationssicherheitspolitik', 'iso27001', false, 'annual', 365, 2.0, 30, 70, 10),
(gen_random_uuid(), t_id, 'ISMS-INT', 'ISMS Interne Audits', 'Audit-Planung, Durchfuehrung, Berichterstattung, Massnahmenverfolgung', 'iso27001', false, 'annual', 365, 2.5, 45, 70, 11),
(gen_random_uuid(), t_id, 'ISMS-AUD', 'ISMS Audit-Vorbereitung', 'Zertifizierungsaudit, Dokumentation, Nachweisfuehrung', 'iso27001', false, 'event_trigger', 365, 2.5, 60, 70, 12),
(gen_random_uuid(), t_id, 'AI-BAS', 'KI-Kompetenz Grundlagen', 'EU AI Act Ueberblick, KI-Risikokategorien, Transparenzpflichten', 'ai_act', false, 'annual', 365, 2.0, 30, 70, 13),
(gen_random_uuid(), t_id, 'AI-ADV', 'KI-Risikomanagement', 'Hochrisiko-KI, Konformitaetsbewertung, Dokumentationspflichten', 'ai_act', false, 'annual', 365, 2.5, 45, 70, 14),
(gen_random_uuid(), t_id, 'AI-RISK', 'Hochrisiko-KI-Systeme', 'Risikomanagementsystem, Qualitaet der Trainingsdaten, Human Oversight', 'ai_act', false, 'event_trigger', 365, 3.0, 60, 80, 15),
(gen_random_uuid(), t_id, 'GESCH-BAS', 'Geschaeftsgeheimnisschutz', 'GeschGehG, Schutzkonzept, NDAs, technische Massnahmen', 'geschgehg', false, 'annual', 365, 2.0, 30, 70, 16),
(gen_random_uuid(), t_id, 'HIN-BAS', 'Hinweisgeberschutz', 'HinSchG, Meldekanal, Vertraulichkeit, Repressalienverbot', 'hinschg', false, 'annual', 365, 2.0, 30, 70, 17),
(gen_random_uuid(), t_id, 'MAIL-SEC', 'E-Mail-Sicherheit', 'Phishing, Spam, Verschluesselung, Sichere Kommunikation', 'iso27001', false, 'micro', 180, 1.5, 15, 70, 18),
(gen_random_uuid(), t_id, 'SOC-ENG', 'Social Engineering Abwehr', 'Manipulationstechniken, Pretexting, Baiting, Tailgating', 'iso27001', false, 'micro', 180, 1.5, 15, 70, 19),
(gen_random_uuid(), t_id, 'PHISH', 'Phishing-Erkennung', 'Phishing-Merkmale, Pruefschritte, Meldeprozess', 'iso27001', false, 'micro', 180, 1.5, 10, 70, 20);
-- Get module IDs for CTM
SELECT id INTO m_gdpr_bas FROM training_modules WHERE tenant_id = t_id AND module_code = 'GDPR-BAS';
SELECT id INTO m_gdpr_adv FROM training_modules WHERE tenant_id = t_id AND module_code = 'GDPR-ADV';
SELECT id INTO m_gdpr_art FROM training_modules WHERE tenant_id = t_id AND module_code = 'GDPR-ART';
SELECT id INTO m_gdpr_dpia FROM training_modules WHERE tenant_id = t_id AND module_code = 'GDPR-DPIA';
SELECT id INTO m_nis2_m1 FROM training_modules WHERE tenant_id = t_id AND module_code = 'NIS2-M1';
SELECT id INTO m_nis2_m2 FROM training_modules WHERE tenant_id = t_id AND module_code = 'NIS2-M2';
SELECT id INTO m_nis2_m3 FROM training_modules WHERE tenant_id = t_id AND module_code = 'NIS2-M3';
SELECT id INTO m_nis2_m4 FROM training_modules WHERE tenant_id = t_id AND module_code = 'NIS2-M4';
SELECT id INTO m_nis2_m5 FROM training_modules WHERE tenant_id = t_id AND module_code = 'NIS2-M5';
SELECT id INTO m_isms_bas FROM training_modules WHERE tenant_id = t_id AND module_code = 'ISMS-BAS';
SELECT id INTO m_isms_int FROM training_modules WHERE tenant_id = t_id AND module_code = 'ISMS-INT';
SELECT id INTO m_isms_aud FROM training_modules WHERE tenant_id = t_id AND module_code = 'ISMS-AUD';
SELECT id INTO m_ai_bas FROM training_modules WHERE tenant_id = t_id AND module_code = 'AI-BAS';
SELECT id INTO m_ai_adv FROM training_modules WHERE tenant_id = t_id AND module_code = 'AI-ADV';
SELECT id INTO m_ai_risk FROM training_modules WHERE tenant_id = t_id AND module_code = 'AI-RISK';
SELECT id INTO m_gesch_bas FROM training_modules WHERE tenant_id = t_id AND module_code = 'GESCH-BAS';
SELECT id INTO m_hin_bas FROM training_modules WHERE tenant_id = t_id AND module_code = 'HIN-BAS';
SELECT id INTO m_mail_sec FROM training_modules WHERE tenant_id = t_id AND module_code = 'MAIL-SEC';
SELECT id INTO m_soc_eng FROM training_modules WHERE tenant_id = t_id AND module_code = 'SOC-ENG';
SELECT id INTO m_phish FROM training_modules WHERE tenant_id = t_id AND module_code = 'PHISH';
-- CTM: R1 Geschaeftsfuehrung
INSERT INTO training_matrix (tenant_id, role_code, module_id, is_mandatory, priority) VALUES
(t_id, 'R1', m_gdpr_bas, true, 1), (t_id, 'R1', m_nis2_m1, true, 1),
(t_id, 'R1', m_nis2_m5, true, 2), (t_id, 'R1', m_isms_bas, true, 2),
(t_id, 'R1', m_ai_bas, true, 3), (t_id, 'R1', m_gesch_bas, true, 3);
-- CTM: R2 IT-Leitung
INSERT INTO training_matrix (tenant_id, role_code, module_id, is_mandatory, priority) VALUES
(t_id, 'R2', m_nis2_m2, true, 1), (t_id, 'R2', m_nis2_m5, true, 1),
(t_id, 'R2', m_isms_bas, true, 2), (t_id, 'R2', m_isms_int, true, 2),
(t_id, 'R2', m_mail_sec, true, 3), (t_id, 'R2', m_soc_eng, true, 3),
(t_id, 'R2', m_phish, true, 3);
-- CTM: R3 DSB
INSERT INTO training_matrix (tenant_id, role_code, module_id, is_mandatory, priority) VALUES
(t_id, 'R3', m_gdpr_bas, true, 1), (t_id, 'R3', m_gdpr_adv, true, 1),
(t_id, 'R3', m_gdpr_art, true, 1), (t_id, 'R3', m_gdpr_dpia, true, 2),
(t_id, 'R3', m_isms_bas, true, 3);
-- CTM: R4 ISB
INSERT INTO training_matrix (tenant_id, role_code, module_id, is_mandatory, priority) VALUES
(t_id, 'R4', m_nis2_m2, true, 1), (t_id, 'R4', m_nis2_m3, true, 1),
(t_id, 'R4', m_nis2_m4, true, 1), (t_id, 'R4', m_nis2_m5, true, 1),
(t_id, 'R4', m_isms_bas, true, 2), (t_id, 'R4', m_isms_int, true, 2),
(t_id, 'R4', m_isms_aud, true, 2);
-- CTM: R5 HR
INSERT INTO training_matrix (tenant_id, role_code, module_id, is_mandatory, priority) VALUES
(t_id, 'R5', m_gdpr_bas, true, 1), (t_id, 'R5', m_gdpr_art, true, 1),
(t_id, 'R5', m_hin_bas, true, 2);
-- CTM: R6 Einkauf
INSERT INTO training_matrix (tenant_id, role_code, module_id, is_mandatory, priority) VALUES
(t_id, 'R6', m_gdpr_bas, true, 1), (t_id, 'R6', m_nis2_m3, true, 1),
(t_id, 'R6', m_gesch_bas, true, 2);
-- CTM: R7 Fachabteilung
INSERT INTO training_matrix (tenant_id, role_code, module_id, is_mandatory, priority) VALUES
(t_id, 'R7', m_gdpr_bas, true, 1), (t_id, 'R7', m_ai_bas, true, 2),
(t_id, 'R7', m_mail_sec, true, 3), (t_id, 'R7', m_soc_eng, true, 3);
-- CTM: R8 IT-Admin
INSERT INTO training_matrix (tenant_id, role_code, module_id, is_mandatory, priority) VALUES
(t_id, 'R8', m_nis2_m2, true, 1), (t_id, 'R8', m_nis2_m5, true, 1),
(t_id, 'R8', m_isms_bas, true, 2), (t_id, 'R8', m_mail_sec, true, 3),
(t_id, 'R8', m_soc_eng, true, 3), (t_id, 'R8', m_phish, true, 3);
-- CTM: R9 Alle Mitarbeiter
INSERT INTO training_matrix (tenant_id, role_code, module_id, is_mandatory, priority) VALUES
(t_id, 'R9', m_gdpr_bas, true, 1), (t_id, 'R9', m_mail_sec, true, 2),
(t_id, 'R9', m_soc_eng, true, 2), (t_id, 'R9', m_phish, true, 2);
RAISE NOTICE 'Training Engine seed data inserted successfully';
END $$;

View File

@@ -0,0 +1,84 @@
-- =========================================================
-- Migration 015: IT-Security Training Modules
-- =========================================================
-- 8 neue IT-Security Micro-/Annual-Trainingsmodule
-- fuer Breakpilot-Tenant
-- =========================================================
DO $$
DECLARE
bp_id UUID := '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e';
b_sec_pwd UUID;
b_sec_desk UUID;
b_sec_kiai UUID;
b_sec_byod UUID;
b_sec_video UUID;
b_sec_usb UUID;
b_sec_inc UUID;
b_sec_home UUID;
BEGIN
-- Skip if already exists
IF EXISTS (SELECT 1 FROM training_modules WHERE tenant_id = bp_id AND module_code = 'SEC-PWD' LIMIT 1) THEN
RAISE NOTICE 'IT-Security modules already exist for Breakpilot tenant, skipping';
RETURN;
END IF;
-- Insert 8 IT-Security modules
INSERT INTO training_modules (id, tenant_id, module_code, title, description, regulation_area, nis2_relevant, frequency_type, validity_days, risk_weight, duration_minutes, pass_threshold, sort_order)
VALUES
(gen_random_uuid(), bp_id, 'SEC-PWD', 'Passwortsicherheit & MFA', 'Sichere Passwoerter, Multi-Faktor-Authentifizierung, Passwort-Manager', 'iso27001', false, 'micro', 180, 1.5, 10, 70, 21),
(gen_random_uuid(), bp_id, 'SEC-DESK', 'Sichere Datenablage & Clean Desk', 'Clean-Desk-Policy, sichere Ablage, Bildschirmsperre, Dokumentenvernichtung', 'iso27001', false, 'micro', 180, 1.5, 10, 70, 22),
(gen_random_uuid(), bp_id, 'SEC-KIAI', 'Personenbezogene Daten in KI-Tools', 'DSGVO-konforme Nutzung von KI, ChatGPT und Co., Datenweitergabe-Risiken', 'dsgvo', false, 'annual', 365, 2.5, 30, 70, 23),
(gen_random_uuid(), bp_id, 'SEC-BYOD', 'BYOD & Mobile Security', 'Bring Your Own Device, Mobile Device Management, Geraetetrennung', 'iso27001', false, 'annual', 365, 2.0, 15, 70, 24),
(gen_random_uuid(), bp_id, 'SEC-VIDEO', 'Sichere Videokonferenzen', 'Datenschutz in Videokonferenzen, Screensharing-Risiken, Aufzeichnungsregeln', 'iso27001', false, 'micro', 180, 1.5, 10, 70, 25),
(gen_random_uuid(), bp_id, 'SEC-USB', 'USB & Externe Medien', 'Risiken externer Datentraeger, USB-Richtlinien, Verschluesselung', 'iso27001', false, 'micro', 180, 1.5, 10, 70, 26),
(gen_random_uuid(), bp_id, 'SEC-INC', 'Sicherheitsvorfall melden', 'Erkennung von Sicherheitsvorfaellen, Meldewege, Sofortmassnahmen, Dokumentation', 'iso27001', true, 'micro', 180, 1.5, 10, 70, 27),
(gen_random_uuid(), bp_id, 'SEC-HOME', 'Homeoffice-Sicherheit', 'Sicheres Arbeiten von zuhause, VPN, WLAN-Sicherheit, physische Sicherheit', 'iso27001', false, 'annual', 365, 2.0, 15, 70, 28);
-- Lookup module IDs
SELECT id INTO b_sec_pwd FROM training_modules WHERE tenant_id = bp_id AND module_code = 'SEC-PWD';
SELECT id INTO b_sec_desk FROM training_modules WHERE tenant_id = bp_id AND module_code = 'SEC-DESK';
SELECT id INTO b_sec_kiai FROM training_modules WHERE tenant_id = bp_id AND module_code = 'SEC-KIAI';
SELECT id INTO b_sec_byod FROM training_modules WHERE tenant_id = bp_id AND module_code = 'SEC-BYOD';
SELECT id INTO b_sec_video FROM training_modules WHERE tenant_id = bp_id AND module_code = 'SEC-VIDEO';
SELECT id INTO b_sec_usb FROM training_modules WHERE tenant_id = bp_id AND module_code = 'SEC-USB';
SELECT id INTO b_sec_inc FROM training_modules WHERE tenant_id = bp_id AND module_code = 'SEC-INC';
SELECT id INTO b_sec_home FROM training_modules WHERE tenant_id = bp_id AND module_code = 'SEC-HOME';
-- CTM: R2 IT-Leitung
INSERT INTO training_matrix (tenant_id, role_code, module_id, is_mandatory, priority) VALUES
(bp_id, 'R2', b_sec_byod, true, 3),
(bp_id, 'R2', b_sec_usb, true, 3),
(bp_id, 'R2', b_sec_inc, true, 2);
-- CTM: R3 DSB
INSERT INTO training_matrix (tenant_id, role_code, module_id, is_mandatory, priority) VALUES
(bp_id, 'R3', b_sec_kiai, true, 2);
-- CTM: R7 Fachabteilung
INSERT INTO training_matrix (tenant_id, role_code, module_id, is_mandatory, priority) VALUES
(bp_id, 'R7', b_sec_pwd, true, 3),
(bp_id, 'R7', b_sec_kiai, true, 3),
(bp_id, 'R7', b_sec_inc, true, 2);
-- CTM: R8 IT-Admin
INSERT INTO training_matrix (tenant_id, role_code, module_id, is_mandatory, priority) VALUES
(bp_id, 'R8', b_sec_pwd, true, 3),
(bp_id, 'R8', b_sec_byod, true, 3),
(bp_id, 'R8', b_sec_usb, true, 3),
(bp_id, 'R8', b_sec_inc, true, 2),
(bp_id, 'R8', b_sec_home, true, 3);
-- CTM: R9 Alle Mitarbeiter
INSERT INTO training_matrix (tenant_id, role_code, module_id, is_mandatory, priority) VALUES
(bp_id, 'R9', b_sec_pwd, true, 3),
(bp_id, 'R9', b_sec_desk, true, 3),
(bp_id, 'R9', b_sec_kiai, true, 3),
(bp_id, 'R9', b_sec_byod, true, 3),
(bp_id, 'R9', b_sec_video, false, 5),
(bp_id, 'R9', b_sec_usb, true, 3),
(bp_id, 'R9', b_sec_inc, true, 2),
(bp_id, 'R9', b_sec_home, true, 3);
RAISE NOTICE 'IT-Security modules inserted for Breakpilot tenant';
END $$;

View File

@@ -0,0 +1,28 @@
-- =========================================================
-- Migration 016: Training Media (Audio/Video)
-- =========================================================
CREATE TABLE IF NOT EXISTS training_media (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
module_id UUID NOT NULL REFERENCES training_modules(id) ON DELETE CASCADE,
content_id UUID REFERENCES training_module_content(id),
media_type VARCHAR(10) NOT NULL CHECK (media_type IN ('audio', 'video')),
status VARCHAR(20) NOT NULL DEFAULT 'processing',
bucket VARCHAR(100) NOT NULL,
object_key VARCHAR(500) NOT NULL,
file_size_bytes BIGINT,
duration_seconds FLOAT,
mime_type VARCHAR(50),
voice_model VARCHAR(100),
language VARCHAR(10) DEFAULT 'de',
metadata JSONB DEFAULT '{}'::jsonb,
error_message TEXT,
generated_by VARCHAR(50),
is_published BOOLEAN DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_training_media_module ON training_media(module_id);
CREATE INDEX IF NOT EXISTS idx_training_media_type ON training_media(module_id, media_type);
CREATE INDEX IF NOT EXISTS idx_training_media_published ON training_media(module_id, media_type, is_published) WHERE is_published = true;