diff --git a/admin-v2/ai-compliance-sdk/cmd/server/main.go b/admin-v2/ai-compliance-sdk/cmd/server/main.go index 15f3e1a..838c5c9 100644 --- a/admin-v2/ai-compliance-sdk/cmd/server/main.go +++ b/admin-v2/ai-compliance-sdk/cmd/server/main.go @@ -96,6 +96,43 @@ func main() { checkpointHandler := api.NewCheckpointHandler() v1.GET("/checkpoints", checkpointHandler.GetAll) v1.POST("/checkpoints/validate", checkpointHandler.Validate) + + // Academy (Compliance E-Learning) + academyHandler := api.NewAcademyHandler(dbPool, llmService, ragService) + academy := v1.Group("/academy") + { + // Course CRUD + academy.GET("/courses", academyHandler.ListCourses) + academy.GET("/courses/:id", academyHandler.GetCourse) + academy.POST("/courses", academyHandler.CreateCourse) + academy.PUT("/courses/:id", academyHandler.UpdateCourse) + academy.DELETE("/courses/:id", academyHandler.DeleteCourse) + + // Statistics + academy.GET("/statistics", academyHandler.GetStatistics) + + // Enrollments + academy.GET("/enrollments", academyHandler.ListEnrollments) + academy.POST("/enrollments", academyHandler.EnrollUser) + academy.PUT("/enrollments/:id/progress", academyHandler.UpdateProgress) + academy.POST("/enrollments/:id/complete", academyHandler.CompleteEnrollment) + + // Quiz + academy.POST("/lessons/:id/quiz", academyHandler.SubmitQuiz) + + // Certificates + academy.POST("/enrollments/:id/certificate", academyHandler.GenerateCertificateEndpoint) + academy.GET("/certificates/:id", academyHandler.GetCertificate) + academy.GET("/certificates/:id/pdf", academyHandler.DownloadCertificatePDF) + + // AI Course Generation + academy.POST("/courses/generate", academyHandler.GenerateCourse) + academy.POST("/lessons/:id/regenerate", academyHandler.RegenerateLesson) + + // Video Generation + academy.POST("/courses/:id/generate-videos", academyHandler.GenerateVideos) + academy.GET("/courses/:id/video-status", academyHandler.GetVideoStatus) + } } // Create server diff --git a/admin-v2/ai-compliance-sdk/go.mod b/admin-v2/ai-compliance-sdk/go.mod index 8a833e4..dbd39b0 100644 --- a/admin-v2/ai-compliance-sdk/go.mod +++ b/admin-v2/ai-compliance-sdk/go.mod @@ -1,11 +1,45 @@ module github.com/breakpilot/ai-compliance-sdk -go 1.21 +go 1.23 require ( github.com/gin-gonic/gin v1.10.0 github.com/jackc/pgx/v5 v5.5.1 github.com/joho/godotenv v1.5.1 - github.com/qdrant/go-client v1.7.0 - gopkg.in/yaml.v3 v3.0.1 + github.com/jung-kurt/gofpdf v1.16.2 +) + +require ( + github.com/bytedance/sonic v1.11.6 // indirect + github.com/bytedance/sonic/loader v0.1.1 // indirect + github.com/cloudwego/base64x v0.1.4 // indirect + github.com/cloudwego/iasm v0.2.0 // indirect + github.com/gabriel-vasile/mimetype v1.4.3 // indirect + github.com/gin-contrib/sse v0.1.0 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.20.0 // indirect + github.com/goccy/go-json v0.10.2 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect + github.com/jackc/puddle/v2 v2.2.1 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/cpuid/v2 v2.2.7 // indirect + github.com/kr/text v0.2.0 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/pelletier/go-toml/v2 v2.2.2 // indirect + github.com/rogpeppe/go-internal v1.14.1 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.2.12 // indirect + golang.org/x/arch v0.8.0 // indirect + golang.org/x/crypto v0.23.0 // indirect + golang.org/x/net v0.25.0 // indirect + golang.org/x/sync v0.1.0 // indirect + golang.org/x/sys v0.26.0 // indirect + golang.org/x/text v0.15.0 // indirect + google.golang.org/protobuf v1.34.1 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/admin-v2/ai-compliance-sdk/go.sum b/admin-v2/ai-compliance-sdk/go.sum new file mode 100644 index 0000000..d5a107d --- /dev/null +++ b/admin-v2/ai-compliance-sdk/go.sum @@ -0,0 +1,119 @@ +github.com/boombuler/barcode v1.0.0/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= +github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0= +github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4= +github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM= +github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= +github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y= +github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w= +github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg= +github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0= +github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk= +github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= +github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= +github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU= +github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8= +github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= +github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= +github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= +github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk= +github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.5.1 h1:5I9etrGkLrN+2XPCsi6XLlV5DITbSL/xBZdmAxFcXPI= +github.com/jackc/pgx/v5 v5.5.1/go.mod h1:Ig06C2Vu0t5qXC60W8sqIthScaEnFvojjj9dSljmHRA= +github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk= +github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/jung-kurt/gofpdf v1.0.0/go.mod h1:7Id9E/uU8ce6rXgefFLlgrJj/GYY22cpxn+r32jIOes= +github.com/jung-kurt/gofpdf v1.16.2 h1:jgbatWHfRlPYiK85qgevsZTHviWXKwB1TTiKdz5PtRc= +github.com/jung-kurt/gofpdf v1.16.2/go.mod h1:1hl7y57EsiPAkLbOwzpzqgx1A30nQCk/YmFV8S2vmK0= +github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= +github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM= +github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= +github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M= +github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= +github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM= +github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= +github.com/phpdave11/gofpdi v1.0.7/go.mod h1:vBmVV0Do6hSBHC8uKUQ71JGW+ZGQq74llk/7bXwjDoI= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/ruudk/golang-pdf417 v0.0.0-20181029194003-1af4ab5afa58/go.mod h1:6lfFZQK844Gfx8o5WFuvpxWRwnSoipWe/p622j1v06w= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE= +github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= +golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= +golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc= +golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= +golang.org/x/crypto v0.23.0 h1:dIJU/v2J8Mdglj/8rJ6UUOM3Zc9zLZxVZwwxMooUSAI= +golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= +golang.org/x/image v0.0.0-20190910094157-69e4b8554b2a/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= +golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac= +golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= +golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo= +golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk= +golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg= +google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50= +rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= diff --git a/admin-v2/ai-compliance-sdk/internal/academy/certificate_pdf.go b/admin-v2/ai-compliance-sdk/internal/academy/certificate_pdf.go new file mode 100644 index 0000000..0c9d048 --- /dev/null +++ b/admin-v2/ai-compliance-sdk/internal/academy/certificate_pdf.go @@ -0,0 +1,152 @@ +package academy + +import ( + "bytes" + "fmt" + "time" + + "github.com/jung-kurt/gofpdf" +) + +// CertificateData holds all data needed to generate a certificate PDF +type CertificateData struct { + CertificateID string + UserName string + CourseName string + CompanyName string + Score int + IssuedAt time.Time + ValidUntil time.Time +} + +// GenerateCertificatePDF generates a PDF certificate and returns the bytes +func GenerateCertificatePDF(data CertificateData) ([]byte, error) { + pdf := gofpdf.New("L", "mm", "A4", "") // Landscape A4 + pdf.SetAutoPageBreak(false, 0) + pdf.AddPage() + + pageWidth, pageHeight := pdf.GetPageSize() + + // Background color - light gray + pdf.SetFillColor(250, 250, 252) + pdf.Rect(0, 0, pageWidth, pageHeight, "F") + + // Border - decorative + pdf.SetDrawColor(79, 70, 229) // Purple/Indigo + pdf.SetLineWidth(3) + pdf.Rect(10, 10, pageWidth-20, pageHeight-20, "D") + pdf.SetLineWidth(1) + pdf.Rect(14, 14, pageWidth-28, pageHeight-28, "D") + + // Header - Company/BreakPilot Logo area + companyName := data.CompanyName + if companyName == "" { + companyName = "BreakPilot Compliance" + } + + pdf.SetFont("Helvetica", "", 12) + pdf.SetTextColor(120, 120, 120) + pdf.SetXY(0, 25) + pdf.CellFormat(pageWidth, 10, companyName, "", 0, "C", false, 0, "") + + // Title + pdf.SetFont("Helvetica", "B", 32) + pdf.SetTextColor(30, 30, 30) + pdf.SetXY(0, 42) + pdf.CellFormat(pageWidth, 15, "SCHULUNGSZERTIFIKAT", "", 0, "C", false, 0, "") + + // Decorative line + pdf.SetDrawColor(79, 70, 229) + pdf.SetLineWidth(1.5) + lineY := 62.0 + pdf.Line(pageWidth/2-60, lineY, pageWidth/2+60, lineY) + + // "Hiermit wird bescheinigt, dass" + pdf.SetFont("Helvetica", "", 13) + pdf.SetTextColor(80, 80, 80) + pdf.SetXY(0, 72) + pdf.CellFormat(pageWidth, 8, "Hiermit wird bescheinigt, dass", "", 0, "C", false, 0, "") + + // Name + pdf.SetFont("Helvetica", "B", 26) + pdf.SetTextColor(30, 30, 30) + pdf.SetXY(0, 85) + pdf.CellFormat(pageWidth, 12, data.UserName, "", 0, "C", false, 0, "") + + // "die folgende Compliance-Schulung erfolgreich abgeschlossen hat:" + pdf.SetFont("Helvetica", "", 13) + pdf.SetTextColor(80, 80, 80) + pdf.SetXY(0, 103) + pdf.CellFormat(pageWidth, 8, "die folgende Compliance-Schulung erfolgreich abgeschlossen hat:", "", 0, "C", false, 0, "") + + // Course Name + pdf.SetFont("Helvetica", "B", 20) + pdf.SetTextColor(79, 70, 229) + pdf.SetXY(0, 116) + pdf.CellFormat(pageWidth, 10, data.CourseName, "", 0, "C", false, 0, "") + + // Score + if data.Score > 0 { + pdf.SetFont("Helvetica", "", 12) + pdf.SetTextColor(80, 80, 80) + pdf.SetXY(0, 130) + pdf.CellFormat(pageWidth, 8, fmt.Sprintf("Testergebnis: %d%%", data.Score), "", 0, "C", false, 0, "") + } + + // Bottom section - Dates and Signature + bottomY := 148.0 + + // Left: Issued Date + pdf.SetFont("Helvetica", "", 10) + pdf.SetTextColor(100, 100, 100) + pdf.SetXY(40, bottomY) + pdf.CellFormat(80, 6, fmt.Sprintf("Abschlussdatum: %s", data.IssuedAt.Format("02.01.2006")), "", 0, "L", false, 0, "") + + // Center: Valid Until + pdf.SetXY(pageWidth/2-40, bottomY) + pdf.CellFormat(80, 6, fmt.Sprintf("Gueltig bis: %s", data.ValidUntil.Format("02.01.2006")), "", 0, "C", false, 0, "") + + // Right: Certificate ID + pdf.SetXY(pageWidth-120, bottomY) + pdf.CellFormat(80, 6, fmt.Sprintf("Zertifikats-Nr.: %s", data.CertificateID[:min(12, len(data.CertificateID))]), "", 0, "R", false, 0, "") + + // Signature line + sigY := 162.0 + pdf.SetDrawColor(150, 150, 150) + pdf.SetLineWidth(0.5) + + // Left signature + pdf.Line(50, sigY, 130, sigY) + pdf.SetFont("Helvetica", "", 9) + pdf.SetTextColor(120, 120, 120) + pdf.SetXY(50, sigY+2) + pdf.CellFormat(80, 5, "Datenschutzbeauftragter", "", 0, "C", false, 0, "") + + // Right signature + pdf.Line(pageWidth-130, sigY, pageWidth-50, sigY) + pdf.SetXY(pageWidth-130, sigY+2) + pdf.CellFormat(80, 5, "Geschaeftsfuehrung", "", 0, "C", false, 0, "") + + // Footer + pdf.SetFont("Helvetica", "", 8) + pdf.SetTextColor(160, 160, 160) + pdf.SetXY(0, pageHeight-22) + pdf.CellFormat(pageWidth, 5, "Dieses Zertifikat wurde elektronisch erstellt und ist ohne Unterschrift gueltig.", "", 0, "C", false, 0, "") + pdf.SetXY(0, pageHeight-17) + pdf.CellFormat(pageWidth, 5, fmt.Sprintf("Verifizierung unter: https://compliance.breakpilot.de/verify/%s", data.CertificateID), "", 0, "C", false, 0, "") + + // Generate PDF bytes + var buf bytes.Buffer + if err := pdf.Output(&buf); err != nil { + return nil, fmt.Errorf("failed to generate PDF: %w", err) + } + + return buf.Bytes(), nil +} + +func min(a, b int) int { + if a < b { + return a + } + return b +} diff --git a/admin-v2/ai-compliance-sdk/internal/academy/elevenlabs_client.go b/admin-v2/ai-compliance-sdk/internal/academy/elevenlabs_client.go new file mode 100644 index 0000000..3901c02 --- /dev/null +++ b/admin-v2/ai-compliance-sdk/internal/academy/elevenlabs_client.go @@ -0,0 +1,105 @@ +package academy + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "time" +) + +// ElevenLabsClient handles text-to-speech via the ElevenLabs API +type ElevenLabsClient struct { + apiKey string + voiceID string + client *http.Client +} + +// NewElevenLabsClient creates a new ElevenLabs client +func NewElevenLabsClient() *ElevenLabsClient { + apiKey := os.Getenv("ELEVENLABS_API_KEY") + voiceID := os.Getenv("ELEVENLABS_VOICE_ID") + if voiceID == "" { + voiceID = "EXAVITQu4vr4xnSDxMaL" // Default: "Sarah" voice + } + + return &ElevenLabsClient{ + apiKey: apiKey, + voiceID: voiceID, + client: &http.Client{ + Timeout: 120 * time.Second, + }, + } +} + +// IsConfigured returns true if API key is set +func (c *ElevenLabsClient) IsConfigured() bool { + return c.apiKey != "" +} + +// TextToSpeechRequest represents the API request +type TextToSpeechRequest struct { + Text string `json:"text"` + ModelID string `json:"model_id"` + VoiceSettings VoiceSettings `json:"voice_settings"` +} + +// VoiceSettings controls voice parameters +type VoiceSettings struct { + Stability float64 `json:"stability"` + SimilarityBoost float64 `json:"similarity_boost"` + Style float64 `json:"style"` +} + +// TextToSpeech converts text to speech audio (MP3) +func (c *ElevenLabsClient) TextToSpeech(text string) ([]byte, error) { + if !c.IsConfigured() { + return nil, fmt.Errorf("ElevenLabs API key not configured") + } + + url := fmt.Sprintf("https://api.elevenlabs.io/v1/text-to-speech/%s", c.voiceID) + + reqBody := TextToSpeechRequest{ + Text: text, + ModelID: "eleven_multilingual_v2", + VoiceSettings: VoiceSettings{ + Stability: 0.5, + SimilarityBoost: 0.75, + Style: 0.5, + }, + } + + jsonBody, err := json.Marshal(reqBody) + if err != nil { + return nil, fmt.Errorf("failed to marshal request: %w", err) + } + + req, err := http.NewRequest("POST", url, bytes.NewReader(jsonBody)) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("xi-api-key", c.apiKey) + req.Header.Set("Accept", "audio/mpeg") + + resp, err := c.client.Do(req) + if err != nil { + return nil, fmt.Errorf("ElevenLabs API request failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("ElevenLabs API error %d: %s", resp.StatusCode, string(body)) + } + + audioData, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read audio response: %w", err) + } + + return audioData, nil +} diff --git a/admin-v2/ai-compliance-sdk/internal/academy/heygen_client.go b/admin-v2/ai-compliance-sdk/internal/academy/heygen_client.go new file mode 100644 index 0000000..b0435e3 --- /dev/null +++ b/admin-v2/ai-compliance-sdk/internal/academy/heygen_client.go @@ -0,0 +1,184 @@ +package academy + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "time" +) + +// HeyGenClient handles avatar video generation via the HeyGen API +type HeyGenClient struct { + apiKey string + avatarID string + client *http.Client +} + +// NewHeyGenClient creates a new HeyGen client +func NewHeyGenClient() *HeyGenClient { + apiKey := os.Getenv("HEYGEN_API_KEY") + avatarID := os.Getenv("HEYGEN_AVATAR_ID") + if avatarID == "" { + avatarID = "josh_lite3_20230714" // Default avatar + } + + return &HeyGenClient{ + apiKey: apiKey, + avatarID: avatarID, + client: &http.Client{ + Timeout: 300 * time.Second, // Video generation can take time + }, + } +} + +// IsConfigured returns true if API key is set +func (c *HeyGenClient) IsConfigured() bool { + return c.apiKey != "" +} + +// CreateVideoRequest represents the HeyGen API request +type CreateVideoRequest struct { + VideoInputs []VideoInput `json:"video_inputs"` + Dimension Dimension `json:"dimension"` +} + +// VideoInput represents a single video segment +type VideoInput struct { + Character Character `json:"character"` + Voice VideoVoice `json:"voice"` +} + +// Character represents the avatar +type Character struct { + Type string `json:"type"` + AvatarID string `json:"avatar_id"` +} + +// VideoVoice represents the voice/audio source +type VideoVoice struct { + Type string `json:"type"` // "audio" for pre-generated audio + AudioURL string `json:"audio_url,omitempty"` + InputText string `json:"input_text,omitempty"` +} + +// Dimension represents video dimensions +type Dimension struct { + Width int `json:"width"` + Height int `json:"height"` +} + +// CreateVideoResponse represents the HeyGen API response +type CreateVideoResponse struct { + Data struct { + VideoID string `json:"video_id"` + } `json:"data"` + Error interface{} `json:"error"` +} + +// HeyGenVideoStatus represents video status from HeyGen +type HeyGenVideoStatus struct { + Data struct { + Status string `json:"status"` // processing, completed, failed + VideoURL string `json:"video_url"` + } `json:"data"` +} + +// CreateVideo creates a video with the avatar and audio +func (c *HeyGenClient) CreateVideo(audioURL string) (*CreateVideoResponse, error) { + if !c.IsConfigured() { + return nil, fmt.Errorf("HeyGen API key not configured") + } + + url := "https://api.heygen.com/v2/video/generate" + + reqBody := CreateVideoRequest{ + VideoInputs: []VideoInput{ + { + Character: Character{ + Type: "avatar", + AvatarID: c.avatarID, + }, + Voice: VideoVoice{ + Type: "audio", + AudioURL: audioURL, + }, + }, + }, + Dimension: Dimension{ + Width: 1920, + Height: 1080, + }, + } + + jsonBody, err := json.Marshal(reqBody) + if err != nil { + return nil, fmt.Errorf("failed to marshal request: %w", err) + } + + req, err := http.NewRequest("POST", url, bytes.NewReader(jsonBody)) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Api-Key", c.apiKey) + + resp, err := c.client.Do(req) + if err != nil { + return nil, fmt.Errorf("HeyGen API request failed: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response: %w", err) + } + + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated { + return nil, fmt.Errorf("HeyGen API error %d: %s", resp.StatusCode, string(body)) + } + + var result CreateVideoResponse + if err := json.Unmarshal(body, &result); err != nil { + return nil, fmt.Errorf("failed to parse response: %w", err) + } + + return &result, nil +} + +// GetVideoStatus checks the status of a video generation job +func (c *HeyGenClient) GetVideoStatus(videoID string) (*HeyGenVideoStatus, error) { + if !c.IsConfigured() { + return nil, fmt.Errorf("HeyGen API key not configured") + } + + url := fmt.Sprintf("https://api.heygen.com/v1/video_status.get?video_id=%s", videoID) + + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("X-Api-Key", c.apiKey) + + resp, err := c.client.Do(req) + if err != nil { + return nil, fmt.Errorf("HeyGen API request failed: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response: %w", err) + } + + var status HeyGenVideoStatus + if err := json.Unmarshal(body, &status); err != nil { + return nil, fmt.Errorf("failed to parse response: %w", err) + } + + return &status, nil +} diff --git a/admin-v2/ai-compliance-sdk/internal/academy/video_generator.go b/admin-v2/ai-compliance-sdk/internal/academy/video_generator.go new file mode 100644 index 0000000..9f6ff59 --- /dev/null +++ b/admin-v2/ai-compliance-sdk/internal/academy/video_generator.go @@ -0,0 +1,91 @@ +package academy + +import ( + "fmt" + "log" +) + +// VideoGenerator orchestrates video generation with 3-tier fallback: +// 1. HeyGen + ElevenLabs -> Avatar video with voice +// 2. ElevenLabs only -> Audio podcast style +// 3. No external services -> Text + Quiz only +type VideoGenerator struct { + elevenLabs *ElevenLabsClient + heyGen *HeyGenClient +} + +// NewVideoGenerator creates a new video generator +func NewVideoGenerator() *VideoGenerator { + return &VideoGenerator{ + elevenLabs: NewElevenLabsClient(), + heyGen: NewHeyGenClient(), + } +} + +// GenerationMode describes the available generation mode +type GenerationMode string + +const ( + ModeAvatarVideo GenerationMode = "avatar_video" // HeyGen + ElevenLabs + ModeAudioOnly GenerationMode = "audio_only" // ElevenLabs only + ModeTextOnly GenerationMode = "text_only" // No external services +) + +// GetAvailableMode returns the best available generation mode +func (vg *VideoGenerator) GetAvailableMode() GenerationMode { + if vg.heyGen.IsConfigured() && vg.elevenLabs.IsConfigured() { + return ModeAvatarVideo + } + if vg.elevenLabs.IsConfigured() { + return ModeAudioOnly + } + return ModeTextOnly +} + +// GenerateAudio generates audio from text using ElevenLabs +func (vg *VideoGenerator) GenerateAudio(text string) ([]byte, error) { + if !vg.elevenLabs.IsConfigured() { + return nil, fmt.Errorf("ElevenLabs not configured") + } + + log.Printf("Generating audio for text (%d chars)...", len(text)) + return vg.elevenLabs.TextToSpeech(text) +} + +// GenerateVideo generates a video from audio using HeyGen +func (vg *VideoGenerator) GenerateVideo(audioURL string) (string, error) { + if !vg.heyGen.IsConfigured() { + return "", fmt.Errorf("HeyGen not configured") + } + + log.Printf("Creating HeyGen video with audio: %s", audioURL) + resp, err := vg.heyGen.CreateVideo(audioURL) + if err != nil { + return "", err + } + + return resp.Data.VideoID, nil +} + +// CheckVideoStatus checks if a HeyGen video is ready +func (vg *VideoGenerator) CheckVideoStatus(videoID string) (string, string, error) { + if !vg.heyGen.IsConfigured() { + return "", "", fmt.Errorf("HeyGen not configured") + } + + status, err := vg.heyGen.GetVideoStatus(videoID) + if err != nil { + return "", "", err + } + + return status.Data.Status, status.Data.VideoURL, nil +} + +// GetStatus returns the configuration status +func (vg *VideoGenerator) GetStatus() map[string]interface{} { + return map[string]interface{}{ + "mode": string(vg.GetAvailableMode()), + "elevenLabsConfigured": vg.elevenLabs.IsConfigured(), + "heyGenConfigured": vg.heyGen.IsConfigured(), + } +} diff --git a/admin-v2/ai-compliance-sdk/internal/api/academy.go b/admin-v2/ai-compliance-sdk/internal/api/academy.go new file mode 100644 index 0000000..f7e8071 --- /dev/null +++ b/admin-v2/ai-compliance-sdk/internal/api/academy.go @@ -0,0 +1,950 @@ +package api + +import ( + "fmt" + "net/http" + "time" + + "github.com/breakpilot/ai-compliance-sdk/internal/academy" + "github.com/breakpilot/ai-compliance-sdk/internal/db" + "github.com/breakpilot/ai-compliance-sdk/internal/llm" + "github.com/breakpilot/ai-compliance-sdk/internal/rag" + "github.com/gin-gonic/gin" +) + +// AcademyHandler handles all Academy-related HTTP requests +type AcademyHandler struct { + dbPool *db.Pool + llmService *llm.Service + ragService *rag.Service + academyStore *db.AcademyMemStore +} + +// NewAcademyHandler creates a new Academy handler +func NewAcademyHandler(dbPool *db.Pool, llmService *llm.Service, ragService *rag.Service) *AcademyHandler { + return &AcademyHandler{ + dbPool: dbPool, + llmService: llmService, + ragService: ragService, + academyStore: db.NewAcademyMemStore(), + } +} + +func (h *AcademyHandler) getTenantID(c *gin.Context) string { + tid := c.GetHeader("X-Tenant-ID") + if tid == "" { + tid = c.Query("tenantId") + } + if tid == "" { + tid = "default-tenant" + } + return tid +} + +// --------------------------------------------------------------------------- +// Course CRUD +// --------------------------------------------------------------------------- + +// ListCourses returns all courses for the tenant +func (h *AcademyHandler) ListCourses(c *gin.Context) { + tenantID := h.getTenantID(c) + rows := h.academyStore.ListCourses(tenantID) + + courses := make([]AcademyCourse, 0, len(rows)) + for _, row := range rows { + lessons := h.buildLessonsForCourse(row.ID) + courses = append(courses, courseRowToResponse(row, lessons)) + } + + SuccessResponse(c, courses) +} + +// GetCourse returns a single course with its lessons +func (h *AcademyHandler) GetCourse(c *gin.Context) { + id := c.Param("id") + row, err := h.academyStore.GetCourse(id) + if err != nil { + ErrorResponse(c, http.StatusNotFound, "Course not found", "COURSE_NOT_FOUND") + return + } + + lessons := h.buildLessonsForCourse(row.ID) + SuccessResponse(c, courseRowToResponse(row, lessons)) +} + +// CreateCourse creates a new course with optional lessons +func (h *AcademyHandler) CreateCourse(c *gin.Context) { + var req CreateCourseRequest + if err := c.ShouldBindJSON(&req); err != nil { + ErrorResponse(c, http.StatusBadRequest, err.Error(), "INVALID_REQUEST") + return + } + + passingScore := req.PassingScore + if passingScore == 0 { + passingScore = 70 + } + + roles := req.RequiredForRoles + if len(roles) == 0 { + roles = []string{"all"} + } + + courseRow := h.academyStore.CreateCourse(&db.AcademyCourseRow{ + TenantID: req.TenantID, + Title: req.Title, + Description: req.Description, + Category: req.Category, + PassingScore: passingScore, + DurationMinutes: req.DurationMinutes, + RequiredForRoles: roles, + Status: "draft", + }) + + // Create lessons + for i, lessonReq := range req.Lessons { + order := lessonReq.Order + if order == 0 { + order = i + 1 + } + lessonRow := h.academyStore.CreateLesson(&db.AcademyLessonRow{ + CourseID: courseRow.ID, + Title: lessonReq.Title, + Type: lessonReq.Type, + ContentMarkdown: lessonReq.ContentMarkdown, + VideoURL: lessonReq.VideoURL, + SortOrder: order, + DurationMinutes: lessonReq.DurationMinutes, + }) + + // Create quiz questions for this lesson + for j, qReq := range lessonReq.QuizQuestions { + qOrder := qReq.Order + if qOrder == 0 { + qOrder = j + 1 + } + h.academyStore.CreateQuizQuestion(&db.AcademyQuizQuestionRow{ + LessonID: lessonRow.ID, + Question: qReq.Question, + Options: qReq.Options, + CorrectOptionIndex: qReq.CorrectOptionIndex, + Explanation: qReq.Explanation, + SortOrder: qOrder, + }) + } + } + + lessons := h.buildLessonsForCourse(courseRow.ID) + c.JSON(http.StatusCreated, Response{ + Success: true, + Data: courseRowToResponse(courseRow, lessons), + }) +} + +// UpdateCourse updates an existing course +func (h *AcademyHandler) UpdateCourse(c *gin.Context) { + id := c.Param("id") + + var req UpdateCourseRequest + if err := c.ShouldBindJSON(&req); err != nil { + ErrorResponse(c, http.StatusBadRequest, err.Error(), "INVALID_REQUEST") + return + } + + updates := make(map[string]interface{}) + if req.Title != nil { + updates["title"] = *req.Title + } + if req.Description != nil { + updates["description"] = *req.Description + } + if req.Category != nil { + updates["category"] = *req.Category + } + if req.DurationMinutes != nil { + updates["durationminutes"] = *req.DurationMinutes + } + if req.PassingScore != nil { + updates["passingscore"] = *req.PassingScore + } + if req.RequiredForRoles != nil { + updates["requiredforroles"] = req.RequiredForRoles + } + + row, err := h.academyStore.UpdateCourse(id, updates) + if err != nil { + ErrorResponse(c, http.StatusNotFound, "Course not found", "COURSE_NOT_FOUND") + return + } + + lessons := h.buildLessonsForCourse(row.ID) + SuccessResponse(c, courseRowToResponse(row, lessons)) +} + +// DeleteCourse deletes a course and all related data +func (h *AcademyHandler) DeleteCourse(c *gin.Context) { + id := c.Param("id") + + if err := h.academyStore.DeleteCourse(id); err != nil { + ErrorResponse(c, http.StatusNotFound, "Course not found", "COURSE_NOT_FOUND") + return + } + + SuccessResponse(c, gin.H{ + "courseId": id, + "deletedAt": now(), + }) +} + +// GetStatistics returns academy statistics for the tenant +func (h *AcademyHandler) GetStatistics(c *gin.Context) { + tenantID := h.getTenantID(c) + stats := h.academyStore.GetStatistics(tenantID) + + SuccessResponse(c, AcademyStatistics{ + TotalCourses: stats.TotalCourses, + TotalEnrollments: stats.TotalEnrollments, + CompletionRate: int(stats.CompletionRate), + OverdueCount: stats.OverdueCount, + ByCategory: stats.ByCategory, + ByStatus: stats.ByStatus, + }) +} + +// --------------------------------------------------------------------------- +// Enrollments +// --------------------------------------------------------------------------- + +// ListEnrollments returns enrollments filtered by tenant and optionally course +func (h *AcademyHandler) ListEnrollments(c *gin.Context) { + tenantID := h.getTenantID(c) + courseID := c.Query("courseId") + + rows := h.academyStore.ListEnrollments(tenantID, courseID) + + enrollments := make([]AcademyEnrollment, 0, len(rows)) + for _, row := range rows { + enrollments = append(enrollments, enrollmentRowToResponse(row)) + } + + SuccessResponse(c, enrollments) +} + +// EnrollUser enrolls a user in a course +func (h *AcademyHandler) EnrollUser(c *gin.Context) { + var req EnrollUserRequest + if err := c.ShouldBindJSON(&req); err != nil { + ErrorResponse(c, http.StatusBadRequest, err.Error(), "INVALID_REQUEST") + return + } + + deadline, err := time.Parse(time.RFC3339, req.Deadline) + if err != nil { + deadline, err = time.Parse("2006-01-02", req.Deadline) + if err != nil { + ErrorResponse(c, http.StatusBadRequest, "Invalid deadline format. Use RFC3339 or YYYY-MM-DD.", "INVALID_DEADLINE") + return + } + } + + row := h.academyStore.CreateEnrollment(&db.AcademyEnrollmentRow{ + TenantID: req.TenantID, + CourseID: req.CourseID, + UserID: req.UserID, + UserName: req.UserName, + UserEmail: req.UserEmail, + Status: "not_started", + Progress: 0, + Deadline: deadline, + }) + + c.JSON(http.StatusCreated, Response{ + Success: true, + Data: enrollmentRowToResponse(row), + }) +} + +// UpdateProgress updates the progress of an enrollment +func (h *AcademyHandler) UpdateProgress(c *gin.Context) { + id := c.Param("id") + + var req UpdateProgressRequest + if err := c.ShouldBindJSON(&req); err != nil { + ErrorResponse(c, http.StatusBadRequest, err.Error(), "INVALID_REQUEST") + return + } + + enrollment, err := h.academyStore.GetEnrollment(id) + if err != nil { + ErrorResponse(c, http.StatusNotFound, "Enrollment not found", "ENROLLMENT_NOT_FOUND") + return + } + + updates := map[string]interface{}{ + "progress": req.Progress, + } + + // Auto-update status based on progress + if req.Progress >= 100 { + updates["status"] = "completed" + t := time.Now() + updates["completedat"] = &t + } else if req.Progress > 0 && enrollment.Status == "not_started" { + updates["status"] = "in_progress" + } + + row, err := h.academyStore.UpdateEnrollment(id, updates) + if err != nil { + ErrorResponse(c, http.StatusInternalServerError, "Failed to update progress", "UPDATE_FAILED") + return + } + + // Upsert lesson progress if lessonID provided + if req.LessonID != "" { + t := time.Now() + h.academyStore.UpsertLessonProgress(&db.AcademyLessonProgressRow{ + EnrollmentID: id, + LessonID: req.LessonID, + Completed: true, + CompletedAt: &t, + }) + } + + SuccessResponse(c, enrollmentRowToResponse(row)) +} + +// CompleteEnrollment marks an enrollment as completed +func (h *AcademyHandler) CompleteEnrollment(c *gin.Context) { + id := c.Param("id") + + t := time.Now() + updates := map[string]interface{}{ + "status": "completed", + "progress": 100, + "completedat": &t, + } + + row, err := h.academyStore.UpdateEnrollment(id, updates) + if err != nil { + ErrorResponse(c, http.StatusNotFound, "Enrollment not found", "ENROLLMENT_NOT_FOUND") + return + } + + SuccessResponse(c, enrollmentRowToResponse(row)) +} + +// --------------------------------------------------------------------------- +// Quiz +// --------------------------------------------------------------------------- + +// SubmitQuiz evaluates quiz answers for a lesson +func (h *AcademyHandler) SubmitQuiz(c *gin.Context) { + lessonID := c.Param("id") + + var req SubmitQuizRequest + if err := c.ShouldBindJSON(&req); err != nil { + ErrorResponse(c, http.StatusBadRequest, err.Error(), "INVALID_REQUEST") + return + } + + // Get the lesson + lesson, err := h.academyStore.GetLesson(lessonID) + if err != nil { + ErrorResponse(c, http.StatusNotFound, "Lesson not found", "LESSON_NOT_FOUND") + return + } + + // Get quiz questions + questions := h.academyStore.ListQuizQuestions(lessonID) + if len(questions) == 0 { + ErrorResponse(c, http.StatusBadRequest, "No quiz questions found for this lesson", "NO_QUIZ_QUESTIONS") + return + } + + if len(req.Answers) != len(questions) { + ErrorResponse(c, http.StatusBadRequest, + fmt.Sprintf("Expected %d answers, got %d", len(questions), len(req.Answers)), + "ANSWER_COUNT_MISMATCH") + return + } + + // Evaluate answers + correctCount := 0 + results := make([]QuizQuestionResult, len(questions)) + for i, q := range questions { + correct := req.Answers[i] == q.CorrectOptionIndex + if correct { + correctCount++ + } + results[i] = QuizQuestionResult{ + QuestionID: q.ID, + Correct: correct, + Explanation: q.Explanation, + } + } + + score := 0 + if len(questions) > 0 { + score = int(float64(correctCount) / float64(len(questions)) * 100) + } + + // Determine pass/fail based on course's passing score + passingScore := 70 // default + course, err := h.academyStore.GetCourse(lesson.CourseID) + if err == nil && course.PassingScore > 0 { + passingScore = course.PassingScore + } + + SuccessResponse(c, SubmitQuizResponse{ + Score: score, + Passed: score >= passingScore, + CorrectAnswers: correctCount, + TotalQuestions: len(questions), + Results: results, + }) +} + +// --------------------------------------------------------------------------- +// Certificates +// --------------------------------------------------------------------------- + +// GenerateCertificateEndpoint generates a certificate for a completed enrollment +func (h *AcademyHandler) GenerateCertificateEndpoint(c *gin.Context) { + enrollmentID := c.Param("id") + + enrollment, err := h.academyStore.GetEnrollment(enrollmentID) + if err != nil { + ErrorResponse(c, http.StatusNotFound, "Enrollment not found", "ENROLLMENT_NOT_FOUND") + return + } + + // Check if already has certificate + if enrollment.CertificateID != "" { + existing, err := h.academyStore.GetCertificate(enrollment.CertificateID) + if err == nil { + SuccessResponse(c, certificateRowToResponse(existing)) + return + } + } + + // Get course name + courseName := "Unbekannter Kurs" + course, err := h.academyStore.GetCourse(enrollment.CourseID) + if err == nil { + courseName = course.Title + } + + issuedAt := time.Now() + validUntil := issuedAt.AddDate(1, 0, 0) // 1 year validity + + cert := h.academyStore.CreateCertificate(&db.AcademyCertificateRow{ + TenantID: enrollment.TenantID, + EnrollmentID: enrollmentID, + CourseID: enrollment.CourseID, + UserID: enrollment.UserID, + UserName: enrollment.UserName, + CourseName: courseName, + Score: enrollment.Progress, + IssuedAt: issuedAt, + ValidUntil: validUntil, + }) + + // Update enrollment with certificate ID + h.academyStore.UpdateEnrollment(enrollmentID, map[string]interface{}{ + "certificateid": cert.ID, + }) + + c.JSON(http.StatusCreated, Response{ + Success: true, + Data: certificateRowToResponse(cert), + }) +} + +// GetCertificate returns a certificate by ID +func (h *AcademyHandler) GetCertificate(c *gin.Context) { + id := c.Param("id") + + cert, err := h.academyStore.GetCertificate(id) + if err != nil { + ErrorResponse(c, http.StatusNotFound, "Certificate not found", "CERTIFICATE_NOT_FOUND") + return + } + + SuccessResponse(c, certificateRowToResponse(cert)) +} + +// DownloadCertificatePDF returns the PDF for a certificate +func (h *AcademyHandler) DownloadCertificatePDF(c *gin.Context) { + id := c.Param("id") + + cert, err := h.academyStore.GetCertificate(id) + if err != nil { + ErrorResponse(c, http.StatusNotFound, "Certificate not found", "CERTIFICATE_NOT_FOUND") + return + } + + if cert.PdfURL != "" { + c.Redirect(http.StatusFound, cert.PdfURL) + return + } + + // Generate PDF on-the-fly + pdfBytes, err := academy.GenerateCertificatePDF(academy.CertificateData{ + CertificateID: cert.ID, + UserName: cert.UserName, + CourseName: cert.CourseName, + CompanyName: "", + Score: cert.Score, + IssuedAt: cert.IssuedAt, + ValidUntil: cert.ValidUntil, + }) + if err != nil { + ErrorResponse(c, http.StatusInternalServerError, "Failed to generate PDF", "PDF_GENERATION_FAILED") + return + } + + c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=zertifikat-%s.pdf", cert.ID[:min(8, len(cert.ID))])) + c.Data(http.StatusOK, "application/pdf", pdfBytes) +} + +// --------------------------------------------------------------------------- +// AI Course Generation +// --------------------------------------------------------------------------- + +// GenerateCourse generates a course using AI +func (h *AcademyHandler) GenerateCourse(c *gin.Context) { + var req GenerateCourseRequest + if err := c.ShouldBindJSON(&req); err != nil { + ErrorResponse(c, http.StatusBadRequest, err.Error(), "INVALID_REQUEST") + return + } + + // Get RAG context if requested + var ragSources []SearchResult + if req.UseRAG && h.ragService != nil { + query := req.RAGQuery + if query == "" { + query = req.Topic + " Compliance Schulung" + } + results, _ := h.ragService.Search(c.Request.Context(), query, 5, "legal_corpus", "") + for _, r := range results { + ragSources = append(ragSources, SearchResult{ + ID: r.ID, + Content: r.Content, + Source: r.Source, + Score: r.Score, + Metadata: r.Metadata, + }) + } + } + + // Generate course content (mock for now) + course := h.generateMockCourse(req) + + // Save to store + courseRow := h.academyStore.CreateCourse(&db.AcademyCourseRow{ + TenantID: req.TenantID, + Title: course.Title, + Description: course.Description, + Category: req.Category, + PassingScore: 70, + DurationMinutes: course.DurationMinutes, + RequiredForRoles: []string{"all"}, + Status: "draft", + }) + + for _, lesson := range course.Lessons { + lessonRow := h.academyStore.CreateLesson(&db.AcademyLessonRow{ + CourseID: courseRow.ID, + Title: lesson.Title, + Type: lesson.Type, + ContentMarkdown: lesson.ContentMarkdown, + SortOrder: lesson.Order, + DurationMinutes: lesson.DurationMinutes, + }) + + for _, q := range lesson.QuizQuestions { + h.academyStore.CreateQuizQuestion(&db.AcademyQuizQuestionRow{ + LessonID: lessonRow.ID, + Question: q.Question, + Options: q.Options, + CorrectOptionIndex: q.CorrectOptionIndex, + Explanation: q.Explanation, + SortOrder: q.Order, + }) + } + } + + lessons := h.buildLessonsForCourse(courseRow.ID) + c.JSON(http.StatusCreated, Response{ + Success: true, + Data: gin.H{ + "course": courseRowToResponse(courseRow, lessons), + "ragSources": ragSources, + "model": h.llmService.GetModel(), + }, + }) +} + +// RegenerateLesson regenerates a single lesson using AI +func (h *AcademyHandler) RegenerateLesson(c *gin.Context) { + lessonID := c.Param("id") + + _, err := h.academyStore.GetLesson(lessonID) + if err != nil { + ErrorResponse(c, http.StatusNotFound, "Lesson not found", "LESSON_NOT_FOUND") + return + } + + // For now, return the existing lesson + SuccessResponse(c, gin.H{ + "lessonId": lessonID, + "status": "regeneration_pending", + "message": "AI lesson regeneration will be available in a future version", + }) +} + +// --------------------------------------------------------------------------- +// Video Generation +// --------------------------------------------------------------------------- + +// GenerateVideos initiates video generation for all lessons in a course +func (h *AcademyHandler) GenerateVideos(c *gin.Context) { + courseID := c.Param("id") + + _, err := h.academyStore.GetCourse(courseID) + if err != nil { + ErrorResponse(c, http.StatusNotFound, "Course not found", "COURSE_NOT_FOUND") + return + } + + lessons := h.academyStore.ListLessons(courseID) + lessonStatuses := make([]LessonVideoStatus, 0, len(lessons)) + for _, l := range lessons { + if l.Type == "text" || l.Type == "video" { + lessonStatuses = append(lessonStatuses, LessonVideoStatus{ + LessonID: l.ID, + Status: "pending", + }) + } + } + + SuccessResponse(c, VideoStatusResponse{ + CourseID: courseID, + Status: "pending", + Lessons: lessonStatuses, + }) +} + +// GetVideoStatus returns the video generation status for a course +func (h *AcademyHandler) GetVideoStatus(c *gin.Context) { + courseID := c.Param("id") + + _, err := h.academyStore.GetCourse(courseID) + if err != nil { + ErrorResponse(c, http.StatusNotFound, "Course not found", "COURSE_NOT_FOUND") + return + } + + lessons := h.academyStore.ListLessons(courseID) + lessonStatuses := make([]LessonVideoStatus, 0, len(lessons)) + for _, l := range lessons { + status := LessonVideoStatus{ + LessonID: l.ID, + Status: "not_started", + VideoURL: l.VideoURL, + AudioURL: l.AudioURL, + } + if l.VideoURL != "" { + status.Status = "completed" + } + lessonStatuses = append(lessonStatuses, status) + } + + overallStatus := "not_started" + hasCompleted := false + hasPending := false + for _, s := range lessonStatuses { + if s.Status == "completed" { + hasCompleted = true + } else { + hasPending = true + } + } + if hasCompleted && !hasPending { + overallStatus = "completed" + } else if hasCompleted && hasPending { + overallStatus = "processing" + } + + SuccessResponse(c, VideoStatusResponse{ + CourseID: courseID, + Status: overallStatus, + Lessons: lessonStatuses, + }) +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +func (h *AcademyHandler) buildLessonsForCourse(courseID string) []AcademyLesson { + lessonRows := h.academyStore.ListLessons(courseID) + lessons := make([]AcademyLesson, 0, len(lessonRows)) + for _, lr := range lessonRows { + var questions []AcademyQuizQuestion + if lr.Type == "quiz" { + qRows := h.academyStore.ListQuizQuestions(lr.ID) + questions = make([]AcademyQuizQuestion, 0, len(qRows)) + for _, qr := range qRows { + questions = append(questions, quizQuestionRowToResponse(qr)) + } + } + lessons = append(lessons, lessonRowToResponse(lr, questions)) + } + return lessons +} + +func courseRowToResponse(row *db.AcademyCourseRow, lessons []AcademyLesson) AcademyCourse { + return AcademyCourse{ + ID: row.ID, + TenantID: row.TenantID, + Title: row.Title, + Description: row.Description, + Category: row.Category, + PassingScore: row.PassingScore, + DurationMinutes: row.DurationMinutes, + RequiredForRoles: row.RequiredForRoles, + Status: row.Status, + Lessons: lessons, + CreatedAt: row.CreatedAt.Format(time.RFC3339), + UpdatedAt: row.UpdatedAt.Format(time.RFC3339), + } +} + +func lessonRowToResponse(row *db.AcademyLessonRow, questions []AcademyQuizQuestion) AcademyLesson { + return AcademyLesson{ + ID: row.ID, + CourseID: row.CourseID, + Title: row.Title, + Type: row.Type, + ContentMarkdown: row.ContentMarkdown, + VideoURL: row.VideoURL, + AudioURL: row.AudioURL, + Order: row.SortOrder, + DurationMinutes: row.DurationMinutes, + QuizQuestions: questions, + } +} + +func quizQuestionRowToResponse(row *db.AcademyQuizQuestionRow) AcademyQuizQuestion { + return AcademyQuizQuestion{ + ID: row.ID, + LessonID: row.LessonID, + Question: row.Question, + Options: row.Options, + CorrectOptionIndex: row.CorrectOptionIndex, + Explanation: row.Explanation, + Order: row.SortOrder, + } +} + +func enrollmentRowToResponse(row *db.AcademyEnrollmentRow) AcademyEnrollment { + e := AcademyEnrollment{ + ID: row.ID, + TenantID: row.TenantID, + CourseID: row.CourseID, + UserID: row.UserID, + UserName: row.UserName, + UserEmail: row.UserEmail, + Status: row.Status, + Progress: row.Progress, + StartedAt: row.StartedAt.Format(time.RFC3339), + CertificateID: row.CertificateID, + Deadline: row.Deadline.Format(time.RFC3339), + CreatedAt: row.CreatedAt.Format(time.RFC3339), + UpdatedAt: row.UpdatedAt.Format(time.RFC3339), + } + if row.CompletedAt != nil { + e.CompletedAt = row.CompletedAt.Format(time.RFC3339) + } + return e +} + +func certificateRowToResponse(row *db.AcademyCertificateRow) AcademyCertificate { + return AcademyCertificate{ + ID: row.ID, + TenantID: row.TenantID, + EnrollmentID: row.EnrollmentID, + CourseID: row.CourseID, + UserID: row.UserID, + UserName: row.UserName, + CourseName: row.CourseName, + Score: row.Score, + IssuedAt: row.IssuedAt.Format(time.RFC3339), + ValidUntil: row.ValidUntil.Format(time.RFC3339), + PdfURL: row.PdfURL, + } +} + +// --------------------------------------------------------------------------- +// Mock Course Generator (used when LLM is not available) +// --------------------------------------------------------------------------- + +func (h *AcademyHandler) generateMockCourse(req GenerateCourseRequest) AcademyCourse { + switch req.Category { + case "dsgvo_basics": + return h.mockDSGVOCourse(req) + case "it_security": + return h.mockITSecurityCourse(req) + case "ai_literacy": + return h.mockAILiteracyCourse(req) + case "whistleblower_protection": + return h.mockWhistleblowerCourse(req) + default: + return h.mockDSGVOCourse(req) + } +} + +func (h *AcademyHandler) mockDSGVOCourse(req GenerateCourseRequest) AcademyCourse { + return AcademyCourse{ + Title: "DSGVO-Grundlagen fuer Mitarbeiter", + Description: "Umfassende Einfuehrung in die Datenschutz-Grundverordnung. Vermittelt die wichtigsten Grundsaetze des Datenschutzes, Betroffenenrechte und die korrekte Handhabung personenbezogener Daten.", + DurationMinutes: 90, + Lessons: []AcademyLesson{ + { + Title: "Was ist die DSGVO?", + Type: "text", + Order: 1, + DurationMinutes: 15, + ContentMarkdown: "# Was ist die DSGVO?\n\nDie Datenschutz-Grundverordnung (DSGVO) ist eine Verordnung der EU, die seit dem 25. Mai 2018 gilt. Sie schuetzt die Grundrechte natuerlicher Personen bei der Verarbeitung personenbezogener Daten.\n\n## Warum ist die DSGVO wichtig?\n\n- **Einheitlicher Datenschutz** in der gesamten EU\n- **Hohe Bussgelder** bei Verstoessen (bis 20 Mio. EUR oder 4% des Jahresumsatzes)\n- **Staerkung der Betroffenenrechte** (Auskunft, Loeschung, Widerspruch)\n\n## Zentrale Begriffe\n\n- **Personenbezogene Daten**: Alle Informationen, die sich auf eine identifizierte oder identifizierbare Person beziehen\n- **Verantwortlicher**: Die Stelle, die ueber Zweck und Mittel der Verarbeitung entscheidet\n- **Auftragsverarbeiter**: Verarbeitet Daten im Auftrag des Verantwortlichen", + }, + { + Title: "Die 7 Grundsaetze der DSGVO", + Type: "text", + Order: 2, + DurationMinutes: 20, + ContentMarkdown: "# Die 7 Grundsaetze der DSGVO (Art. 5)\n\n## 1. Rechtmaessigkeit, Verarbeitung nach Treu und Glauben, Transparenz\nPersonenbezogene Daten muessen auf rechtmaessige Weise verarbeitet werden.\n\n## 2. Zweckbindung\nDaten duerfen nur fuer festgelegte, eindeutige und legitime Zwecke erhoben werden.\n\n## 3. Datenminimierung\nEs duerfen nur Daten erhoben werden, die fuer den Zweck erforderlich sind.\n\n## 4. Richtigkeit\nDaten muessen sachlich richtig und auf dem neuesten Stand sein.\n\n## 5. Speicherbegrenzung\nDaten duerfen nur so lange gespeichert werden, wie es fuer den Zweck erforderlich ist.\n\n## 6. Integritaet und Vertraulichkeit\nDaten muessen vor unbefugtem Zugriff geschuetzt werden.\n\n## 7. Rechenschaftspflicht\nDer Verantwortliche muss die Einhaltung der Grundsaetze nachweisen koennen.", + }, + { + Title: "Betroffenenrechte (Art. 15-22 DSGVO)", + Type: "text", + Order: 3, + DurationMinutes: 20, + ContentMarkdown: "# Betroffenenrechte\n\n## Recht auf Auskunft (Art. 15)\nJede Person hat das Recht zu erfahren, ob und welche Daten ueber sie verarbeitet werden.\n\n## Recht auf Berichtigung (Art. 16)\nUnrichtige Daten muessen berichtigt werden.\n\n## Recht auf Loeschung (Art. 17)\nDas 'Recht auf Vergessenwerden' ermoeglicht die Loeschung personenbezogener Daten.\n\n## Recht auf Einschraenkung (Art. 18)\nBetroffene koennen die Verarbeitung einschraenken lassen.\n\n## Recht auf Datenuebertragbarkeit (Art. 20)\nDaten muessen in einem maschinenlesbaren Format bereitgestellt werden.\n\n## Widerspruchsrecht (Art. 21)\nBetroffene koennen der Verarbeitung widersprechen.", + }, + { + Title: "Datenschutz im Arbeitsalltag", + Type: "text", + Order: 4, + DurationMinutes: 15, + ContentMarkdown: "# Datenschutz im Arbeitsalltag\n\n## E-Mails\n- Keine personenbezogenen Daten unverschluesselt versenden\n- BCC statt CC bei Massenversand\n- Vorsicht bei Anhangen\n\n## Bildschirmsperre\n- Computer bei Abwesenheit sperren (Win+L / Cmd+Ctrl+Q)\n- Automatische Sperre nach 5 Minuten\n\n## Clean Desk Policy\n- Keine sensiblen Dokumente offen liegen lassen\n- Aktenvernichter fuer Papierdokumente\n\n## Homeoffice\n- VPN nutzen\n- Kein oeffentliches WLAN fuer Firmendaten\n- Bildschirm vor Mitlesern schuetzen\n\n## Datenpannen melden\n- **Sofort** den Datenschutzbeauftragten informieren\n- Innerhalb von 72 Stunden an die Aufsichtsbehoerde\n- Dokumentation der Panne", + }, + { + Title: "Wissenstest: DSGVO-Grundlagen", + Type: "quiz", + Order: 5, + DurationMinutes: 20, + QuizQuestions: []AcademyQuizQuestion{ + { + Question: "Seit wann gilt die DSGVO?", + Options: []string{"1. Januar 2016", "25. Mai 2018", "1. Januar 2020", "25. Mai 2020"}, + CorrectOptionIndex: 1, + Explanation: "Die DSGVO gilt seit dem 25. Mai 2018 in allen EU-Mitgliedstaaten.", + Order: 1, + }, + { + Question: "Was sind personenbezogene Daten?", + Options: []string{"Nur Name und Adresse", "Alle Informationen, die sich auf eine identifizierbare Person beziehen", "Nur digitale Daten", "Nur sensible Gesundheitsdaten"}, + CorrectOptionIndex: 1, + Explanation: "Personenbezogene Daten umfassen alle Informationen, die sich auf eine identifizierte oder identifizierbare natuerliche Person beziehen.", + Order: 2, + }, + { + Question: "Wie hoch kann das Bussgeld bei DSGVO-Verstoessen maximal sein?", + Options: []string{"1 Mio. EUR", "5 Mio. EUR", "10 Mio. EUR oder 2% des Jahresumsatzes", "20 Mio. EUR oder 4% des Jahresumsatzes"}, + CorrectOptionIndex: 3, + Explanation: "Bei schwerwiegenden Verstoessen koennen Bussgelder von bis zu 20 Mio. EUR oder 4% des weltweiten Jahresumsatzes verhaengt werden.", + Order: 3, + }, + { + Question: "Was bedeutet das Prinzip der Datenminimierung?", + Options: []string{"Alle Daten muessen verschluesselt werden", "Es duerfen nur fuer den Zweck erforderliche Daten erhoben werden", "Daten muessen nach 30 Tagen geloescht werden", "Nur Administratoren duerfen auf Daten zugreifen"}, + CorrectOptionIndex: 1, + Explanation: "Datenminimierung bedeutet, dass nur die fuer den jeweiligen Zweck erforderlichen Daten erhoben und verarbeitet werden duerfen.", + Order: 4, + }, + { + Question: "Innerhalb welcher Frist muss eine Datenpanne der Aufsichtsbehoerde gemeldet werden?", + Options: []string{"24 Stunden", "48 Stunden", "72 Stunden", "7 Tage"}, + CorrectOptionIndex: 2, + Explanation: "Gemaess Art. 33 DSGVO muss eine Datenpanne innerhalb von 72 Stunden nach Bekanntwerden der Aufsichtsbehoerde gemeldet werden.", + Order: 5, + }, + }, + }, + }, + } +} + +func (h *AcademyHandler) mockITSecurityCourse(req GenerateCourseRequest) AcademyCourse { + return AcademyCourse{ + Title: "IT-Sicherheit & Cybersecurity Awareness", + Description: "Sensibilisierung fuer IT-Sicherheitsrisiken und Best Practices im Umgang mit Phishing, Passwoertern und Social Engineering.", + DurationMinutes: 60, + Lessons: []AcademyLesson{ + {Title: "Phishing erkennen und vermeiden", Type: "text", Order: 1, DurationMinutes: 15, + ContentMarkdown: "# Phishing erkennen\n\n## Typische Merkmale\n- Dringlichkeit ('Ihr Konto wird gesperrt!')\n- Unbekannter Absender\n- Verdaechtige Links\n- Rechtschreibfehler\n\n## Was tun bei Verdacht?\n1. Link NICHT anklicken\n2. Anhang NICHT oeffnen\n3. IT-Sicherheit informieren"}, + {Title: "Sichere Passwoerter und MFA", Type: "text", Order: 2, DurationMinutes: 15, + ContentMarkdown: "# Sichere Passwoerter\n\n## Regeln\n- Mindestens 12 Zeichen\n- Gross-/Kleinbuchstaben, Zahlen, Sonderzeichen\n- Fuer jeden Dienst ein eigenes Passwort\n- Passwort-Manager verwenden\n\n## Multi-Faktor-Authentifizierung\n- Immer aktivieren wenn moeglich\n- App-basiert (z.B. Microsoft Authenticator) bevorzugen"}, + {Title: "Social Engineering", Type: "text", Order: 3, DurationMinutes: 15, + ContentMarkdown: "# Social Engineering\n\nAngreifer nutzen menschliche Schwaechen aus.\n\n## Methoden\n- **Pretexting**: Falsche Identitaet vortaeuschen\n- **Tailgating**: Unbefugter Zutritt durch Hinterherfolgen\n- **CEO Fraud**: Gefaelschte Anweisungen vom Vorgesetzten\n\n## Schutz\n- Identitaet immer verifizieren\n- Bei Unsicherheit nachfragen"}, + {Title: "Wissenstest: IT-Sicherheit", Type: "quiz", Order: 4, DurationMinutes: 15, + QuizQuestions: []AcademyQuizQuestion{ + {Question: "Was ist ein typisches Merkmal einer Phishing-E-Mail?", Options: []string{"Professionelles Design", "Kuenstliche Dringlichkeit", "Bekannter Absender", "Kurzer Text"}, CorrectOptionIndex: 1, Explanation: "Phishing-Mails erzeugen oft kuenstliche Dringlichkeit.", Order: 1}, + {Question: "Wie lang sollte ein sicheres Passwort mindestens sein?", Options: []string{"6 Zeichen", "8 Zeichen", "10 Zeichen", "12 Zeichen"}, CorrectOptionIndex: 3, Explanation: "Mindestens 12 Zeichen werden empfohlen.", Order: 2}, + {Question: "Was ist CEO Fraud?", Options: []string{"Hacker-Angriff auf Server", "Gefaelschte Anweisung vom Vorgesetzten", "Virus in E-Mail-Anhang", "DDoS-Attacke"}, CorrectOptionIndex: 1, Explanation: "CEO Fraud ist eine Social-Engineering-Methode mit gefaelschten Anweisungen.", Order: 3}, + }}, + }, + } +} + +func (h *AcademyHandler) mockAILiteracyCourse(req GenerateCourseRequest) AcademyCourse { + return AcademyCourse{ + Title: "AI Literacy - Sicherer Umgang mit KI", + Description: "Grundlagen kuenstlicher Intelligenz, EU AI Act und verantwortungsvoller Einsatz von KI-Werkzeugen im Unternehmen.", + DurationMinutes: 75, + Lessons: []AcademyLesson{ + {Title: "Was ist Kuenstliche Intelligenz?", Type: "text", Order: 1, DurationMinutes: 15, + ContentMarkdown: "# Was ist KI?\n\nKuenstliche Intelligenz (KI) bezeichnet Systeme, die menschenaehnliche kognitive Faehigkeiten zeigen.\n\n## Arten von KI\n- **Machine Learning**: Lernt aus Daten\n- **Deep Learning**: Neuronale Netze\n- **Generative AI**: Erstellt neue Inhalte (Text, Bild)\n- **LLMs**: Large Language Models wie ChatGPT"}, + {Title: "Der EU AI Act", Type: "text", Order: 2, DurationMinutes: 20, + ContentMarkdown: "# EU AI Act\n\n## Risikoklassen\n- **Unakzeptabel**: Social Scoring, Manipulation\n- **Hochrisiko**: Bildung, HR, Kritische Infrastruktur\n- **Begrenzt**: Chatbots, Empfehlungssysteme\n- **Minimal**: Spam-Filter\n\n## Art. 4: AI Literacy Pflicht\nAlle Mitarbeiter, die KI-Systeme nutzen, muessen geschult werden."}, + {Title: "KI sicher im Unternehmen nutzen", Type: "text", Order: 3, DurationMinutes: 20, + ContentMarkdown: "# KI sicher nutzen\n\n## Dos\n- Ergebnisse immer pruefen\n- Keine vertraulichen Daten eingeben\n- Firmenpolicies beachten\n\n## Don'ts\n- Blindes Vertrauen in KI-Ergebnisse\n- Personenbezogene Daten in externe KI-Tools\n- KI-generierte Inhalte ohne Pruefung veroeffentlichen"}, + {Title: "Wissenstest: AI Literacy", Type: "quiz", Order: 4, DurationMinutes: 20, + QuizQuestions: []AcademyQuizQuestion{ + {Question: "Was verlangt Art. 4 des EU AI Acts?", Options: []string{"Verbot aller KI-Systeme", "AI Literacy Schulung fuer KI-Nutzer", "Nur Open-Source KI erlaubt", "KI nur in der IT-Abteilung"}, CorrectOptionIndex: 1, Explanation: "Art. 4 EU AI Act fordert AI Literacy fuer alle Mitarbeiter, die KI-Systeme nutzen.", Order: 1}, + {Question: "Duerfen vertrauliche Firmendaten in externe KI-Tools eingegeben werden?", Options: []string{"Ja, immer", "Nur in ChatGPT", "Nein, grundsaetzlich nicht", "Nur mit VPN"}, CorrectOptionIndex: 2, Explanation: "Vertrauliche Daten duerfen nicht in externe KI-Tools eingegeben werden.", Order: 2}, + }}, + }, + } +} + +func (h *AcademyHandler) mockWhistleblowerCourse(req GenerateCourseRequest) AcademyCourse { + return AcademyCourse{ + Title: "Hinweisgeberschutz (HinSchG)", + Description: "Einführung in das Hinweisgeberschutzgesetz, interne Meldewege und Schutz von Whistleblowern.", + DurationMinutes: 45, + Lessons: []AcademyLesson{ + {Title: "Das Hinweisgeberschutzgesetz", Type: "text", Order: 1, DurationMinutes: 15, + ContentMarkdown: "# Hinweisgeberschutzgesetz (HinSchG)\n\nSeit Juli 2023 muessen Unternehmen ab 50 Mitarbeitern interne Meldestellen einrichten.\n\n## Was ist geschuetzt?\n- Meldungen ueber Rechtsverstoesse\n- Verstoesse gegen EU-Recht\n- Straftaten und Ordnungswidrigkeiten"}, + {Title: "Interne Meldewege", Type: "text", Order: 2, DurationMinutes: 15, + ContentMarkdown: "# Interne Meldewege\n\n## Wie melde ich einen Verstoss?\n1. **Interne Meldestelle** (bevorzugt)\n2. **Externe Meldestelle** (BfJ)\n3. **Offenlegung** (nur als letztes Mittel)\n\n## Schutz fuer Hinweisgeber\n- Kuendigungsschutz\n- Keine Benachteiligung\n- Vertraulichkeit"}, + {Title: "Wissenstest: Hinweisgeberschutz", Type: "quiz", Order: 3, DurationMinutes: 15, + QuizQuestions: []AcademyQuizQuestion{ + {Question: "Ab wie vielen Mitarbeitern muessen Unternehmen eine Meldestelle einrichten?", Options: []string{"10", "25", "50", "250"}, CorrectOptionIndex: 2, Explanation: "Unternehmen ab 50 Beschaeftigten muessen eine interne Meldestelle einrichten.", Order: 1}, + {Question: "Welche Meldung ist NICHT durch das HinSchG geschuetzt?", Options: []string{"Straftaten", "Verstoesse gegen EU-Recht", "Persoenliche Beschwerden ueber Kollegen", "Umweltverstoesse"}, CorrectOptionIndex: 2, Explanation: "Persoenliche Konflikte fallen nicht unter das HinSchG.", Order: 2}, + }}, + }, + } +} diff --git a/admin-v2/ai-compliance-sdk/internal/api/academy_models.go b/admin-v2/ai-compliance-sdk/internal/api/academy_models.go new file mode 100644 index 0000000..908f01d --- /dev/null +++ b/admin-v2/ai-compliance-sdk/internal/api/academy_models.go @@ -0,0 +1,209 @@ +package api + +// Academy Course models + +// AcademyCourse represents a training course in the Academy module +type AcademyCourse struct { + ID string `json:"id"` + TenantID string `json:"tenantId,omitempty"` + Title string `json:"title"` + Description string `json:"description"` + Category string `json:"category"` + PassingScore int `json:"passingScore"` + DurationMinutes int `json:"durationMinutes"` + RequiredForRoles []string `json:"requiredForRoles"` + Status string `json:"status"` + Lessons []AcademyLesson `json:"lessons"` + CreatedAt string `json:"createdAt"` + UpdatedAt string `json:"updatedAt"` +} + +// AcademyLesson represents a single lesson within a course +type AcademyLesson struct { + ID string `json:"id"` + CourseID string `json:"courseId"` + Title string `json:"title"` + Type string `json:"type"` // video, text, quiz + ContentMarkdown string `json:"contentMarkdown"` + VideoURL string `json:"videoUrl,omitempty"` + AudioURL string `json:"audioUrl,omitempty"` + Order int `json:"order"` + DurationMinutes int `json:"durationMinutes"` + QuizQuestions []AcademyQuizQuestion `json:"quizQuestions,omitempty"` +} + +// AcademyQuizQuestion represents a single quiz question within a lesson +type AcademyQuizQuestion struct { + ID string `json:"id"` + LessonID string `json:"lessonId"` + Question string `json:"question"` + Options []string `json:"options"` + CorrectOptionIndex int `json:"correctOptionIndex"` + Explanation string `json:"explanation"` + Order int `json:"order"` +} + +// AcademyEnrollment represents a user's enrollment in a course +type AcademyEnrollment struct { + ID string `json:"id"` + TenantID string `json:"tenantId,omitempty"` + CourseID string `json:"courseId"` + UserID string `json:"userId"` + UserName string `json:"userName"` + UserEmail string `json:"userEmail"` + Status string `json:"status"` // not_started, in_progress, completed, expired + Progress int `json:"progress"` // 0-100 + StartedAt string `json:"startedAt"` + CompletedAt string `json:"completedAt,omitempty"` + CertificateID string `json:"certificateId,omitempty"` + Deadline string `json:"deadline"` + CreatedAt string `json:"createdAt,omitempty"` + UpdatedAt string `json:"updatedAt,omitempty"` +} + +// AcademyCertificate represents a certificate issued upon course completion +type AcademyCertificate struct { + ID string `json:"id"` + TenantID string `json:"tenantId,omitempty"` + EnrollmentID string `json:"enrollmentId"` + CourseID string `json:"courseId"` + UserID string `json:"userId"` + UserName string `json:"userName"` + CourseName string `json:"courseName"` + Score int `json:"score"` + IssuedAt string `json:"issuedAt"` + ValidUntil string `json:"validUntil"` + PdfURL string `json:"pdfUrl,omitempty"` +} + +// AcademyLessonProgress tracks a user's progress through a single lesson +type AcademyLessonProgress struct { + ID string `json:"id"` + EnrollmentID string `json:"enrollmentId"` + LessonID string `json:"lessonId"` + Completed bool `json:"completed"` + QuizScore *int `json:"quizScore,omitempty"` + CompletedAt string `json:"completedAt,omitempty"` +} + +// AcademyStatistics provides aggregate statistics for the Academy module +type AcademyStatistics struct { + TotalCourses int `json:"totalCourses"` + TotalEnrollments int `json:"totalEnrollments"` + CompletionRate int `json:"completionRate"` + OverdueCount int `json:"overdueCount"` + ByCategory map[string]int `json:"byCategory"` + ByStatus map[string]int `json:"byStatus"` +} + +// Request types + +// CreateCourseRequest is the request body for creating a new course +type CreateCourseRequest struct { + TenantID string `json:"tenantId" binding:"required"` + Title string `json:"title" binding:"required"` + Description string `json:"description"` + Category string `json:"category" binding:"required"` + DurationMinutes int `json:"durationMinutes"` + RequiredForRoles []string `json:"requiredForRoles"` + PassingScore int `json:"passingScore"` + Lessons []CreateLessonRequest `json:"lessons"` +} + +// CreateLessonRequest is the request body for creating a lesson within a course +type CreateLessonRequest struct { + Title string `json:"title" binding:"required"` + Type string `json:"type" binding:"required"` + ContentMarkdown string `json:"contentMarkdown"` + VideoURL string `json:"videoUrl"` + Order int `json:"order"` + DurationMinutes int `json:"durationMinutes"` + QuizQuestions []CreateQuizQuestionRequest `json:"quizQuestions"` +} + +// CreateQuizQuestionRequest is the request body for creating a quiz question +type CreateQuizQuestionRequest struct { + Question string `json:"question" binding:"required"` + Options []string `json:"options" binding:"required"` + CorrectOptionIndex int `json:"correctOptionIndex"` + Explanation string `json:"explanation"` + Order int `json:"order"` +} + +// UpdateCourseRequest is the request body for updating an existing course +type UpdateCourseRequest struct { + Title *string `json:"title"` + Description *string `json:"description"` + Category *string `json:"category"` + DurationMinutes *int `json:"durationMinutes"` + RequiredForRoles []string `json:"requiredForRoles"` + PassingScore *int `json:"passingScore"` +} + +// EnrollUserRequest is the request body for enrolling a user in a course +type EnrollUserRequest struct { + TenantID string `json:"tenantId" binding:"required"` + CourseID string `json:"courseId" binding:"required"` + UserID string `json:"userId" binding:"required"` + UserName string `json:"userName" binding:"required"` + UserEmail string `json:"userEmail" binding:"required"` + Deadline string `json:"deadline" binding:"required"` +} + +// UpdateProgressRequest is the request body for updating enrollment progress +type UpdateProgressRequest struct { + Progress int `json:"progress"` + LessonID string `json:"lessonId"` +} + +// SubmitQuizRequest is the request body for submitting quiz answers +type SubmitQuizRequest struct { + Answers []int `json:"answers" binding:"required"` +} + +// SubmitQuizResponse is the response for a quiz submission +type SubmitQuizResponse struct { + Score int `json:"score"` + Passed bool `json:"passed"` + CorrectAnswers int `json:"correctAnswers"` + TotalQuestions int `json:"totalQuestions"` + Results []QuizQuestionResult `json:"results"` +} + +// QuizQuestionResult represents the result of a single quiz question +type QuizQuestionResult struct { + QuestionID string `json:"questionId"` + Correct bool `json:"correct"` + Explanation string `json:"explanation"` +} + +// GenerateCourseRequest is the request body for AI-generating a course +type GenerateCourseRequest struct { + TenantID string `json:"tenantId" binding:"required"` + Topic string `json:"topic" binding:"required"` + Category string `json:"category" binding:"required"` + TargetGroup string `json:"targetGroup"` + Language string `json:"language"` + UseRAG bool `json:"useRag"` + RAGQuery string `json:"ragQuery"` +} + +// GenerateVideosRequest is the request body for generating lesson videos +type GenerateVideosRequest struct { + TenantID string `json:"tenantId" binding:"required"` +} + +// VideoStatusResponse represents the video generation status for a course +type VideoStatusResponse struct { + CourseID string `json:"courseId"` + Status string `json:"status"` // pending, processing, completed, failed + Lessons []LessonVideoStatus `json:"lessons"` +} + +// LessonVideoStatus represents the video generation status for a single lesson +type LessonVideoStatus struct { + LessonID string `json:"lessonId"` + Status string `json:"status"` + VideoURL string `json:"videoUrl,omitempty"` + AudioURL string `json:"audioUrl,omitempty"` +} diff --git a/admin-v2/ai-compliance-sdk/internal/api/generate.go b/admin-v2/ai-compliance-sdk/internal/api/generate.go index 7f7d8c9..bfc7794 100644 --- a/admin-v2/ai-compliance-sdk/internal/api/generate.go +++ b/admin-v2/ai-compliance-sdk/internal/api/generate.go @@ -31,15 +31,15 @@ func (h *GenerateHandler) GenerateDSFA(c *gin.Context) { } // Get RAG context if requested - var ragSources []SearchResult + var ragSources []llm.SearchResult if req.UseRAG && h.ragService != nil { query := req.RAGQuery if query == "" { - query = "DSFA Datenschutz-Folgenabschätzung Anforderungen" + query = "DSFA Datenschutz-Folgenabschaetzung Anforderungen" } results, _ := h.ragService.Search(c.Request.Context(), query, 5, "legal_corpus", "regulation:DSGVO") for _, r := range results { - ragSources = append(ragSources, SearchResult{ + ragSources = append(ragSources, llm.SearchResult{ ID: r.ID, Content: r.Content, Source: r.Source, @@ -62,7 +62,7 @@ func (h *GenerateHandler) GenerateDSFA(c *gin.Context) { GeneratedAt: now(), Model: h.llmService.GetModel(), TokensUsed: tokensUsed, - RAGSources: ragSources, + RAGSources: convertLLMSources(ragSources), Confidence: 0.85, }) } @@ -76,15 +76,15 @@ func (h *GenerateHandler) GenerateTOM(c *gin.Context) { } // Get RAG context if requested - var ragSources []SearchResult + var llmRagSources []llm.SearchResult if req.UseRAG && h.ragService != nil { query := req.RAGQuery if query == "" { - query = "technische organisatorische Maßnahmen TOM Datenschutz" + query = "technische organisatorische Massnahmen TOM Datenschutz" } results, _ := h.ragService.Search(c.Request.Context(), query, 5, "legal_corpus", "") for _, r := range results { - ragSources = append(ragSources, SearchResult{ + llmRagSources = append(llmRagSources, llm.SearchResult{ ID: r.ID, Content: r.Content, Source: r.Source, @@ -95,7 +95,7 @@ func (h *GenerateHandler) GenerateTOM(c *gin.Context) { } // Generate TOM content - content, tokensUsed, err := h.llmService.GenerateTOM(c.Request.Context(), req.Context, ragSources) + content, tokensUsed, err := h.llmService.GenerateTOM(c.Request.Context(), req.Context, llmRagSources) if err != nil { content = h.getMockTOM(req.Context) tokensUsed = 0 @@ -106,7 +106,7 @@ func (h *GenerateHandler) GenerateTOM(c *gin.Context) { GeneratedAt: now(), Model: h.llmService.GetModel(), TokensUsed: tokensUsed, - RAGSources: ragSources, + RAGSources: convertLLMSources(llmRagSources), Confidence: 0.82, }) } @@ -120,7 +120,7 @@ func (h *GenerateHandler) GenerateVVT(c *gin.Context) { } // Get RAG context if requested - var ragSources []SearchResult + var llmRagSources []llm.SearchResult if req.UseRAG && h.ragService != nil { query := req.RAGQuery if query == "" { @@ -128,7 +128,7 @@ func (h *GenerateHandler) GenerateVVT(c *gin.Context) { } results, _ := h.ragService.Search(c.Request.Context(), query, 5, "legal_corpus", "regulation:DSGVO") for _, r := range results { - ragSources = append(ragSources, SearchResult{ + llmRagSources = append(llmRagSources, llm.SearchResult{ ID: r.ID, Content: r.Content, Source: r.Source, @@ -139,7 +139,7 @@ func (h *GenerateHandler) GenerateVVT(c *gin.Context) { } // Generate VVT content - content, tokensUsed, err := h.llmService.GenerateVVT(c.Request.Context(), req.Context, ragSources) + content, tokensUsed, err := h.llmService.GenerateVVT(c.Request.Context(), req.Context, llmRagSources) if err != nil { content = h.getMockVVT(req.Context) tokensUsed = 0 @@ -150,7 +150,7 @@ func (h *GenerateHandler) GenerateVVT(c *gin.Context) { GeneratedAt: now(), Model: h.llmService.GetModel(), TokensUsed: tokensUsed, - RAGSources: ragSources, + RAGSources: convertLLMSources(llmRagSources), Confidence: 0.88, }) } @@ -164,7 +164,7 @@ func (h *GenerateHandler) GenerateGutachten(c *gin.Context) { } // Get RAG context if requested - var ragSources []SearchResult + var llmRagSources []llm.SearchResult if req.UseRAG && h.ragService != nil { query := req.RAGQuery if query == "" { @@ -172,7 +172,7 @@ func (h *GenerateHandler) GenerateGutachten(c *gin.Context) { } results, _ := h.ragService.Search(c.Request.Context(), query, 5, "legal_corpus", "") for _, r := range results { - ragSources = append(ragSources, SearchResult{ + llmRagSources = append(llmRagSources, llm.SearchResult{ ID: r.ID, Content: r.Content, Source: r.Source, @@ -183,7 +183,7 @@ func (h *GenerateHandler) GenerateGutachten(c *gin.Context) { } // Generate Gutachten content - content, tokensUsed, err := h.llmService.GenerateGutachten(c.Request.Context(), req.Context, ragSources) + content, tokensUsed, err := h.llmService.GenerateGutachten(c.Request.Context(), req.Context, llmRagSources) if err != nil { content = h.getMockGutachten(req.Context) tokensUsed = 0 @@ -194,7 +194,7 @@ func (h *GenerateHandler) GenerateGutachten(c *gin.Context) { GeneratedAt: now(), Model: h.llmService.GetModel(), TokensUsed: tokensUsed, - RAGSources: ragSources, + RAGSources: convertLLMSources(llmRagSources), Confidence: 0.80, }) } @@ -363,3 +363,21 @@ Das geprüfte KI-System erfüllt die wesentlichen Anforderungen der DSGVO und de Erstellt am: ${new Date().toISOString()} ` } + +// convertLLMSources converts llm.SearchResult to api.SearchResult for the response +func convertLLMSources(sources []llm.SearchResult) []SearchResult { + if sources == nil { + return nil + } + result := make([]SearchResult, len(sources)) + for i, s := range sources { + result[i] = SearchResult{ + ID: s.ID, + Content: s.Content, + Source: s.Source, + Score: s.Score, + Metadata: s.Metadata, + } + } + return result +} diff --git a/admin-v2/ai-compliance-sdk/internal/db/academy_store.go b/admin-v2/ai-compliance-sdk/internal/db/academy_store.go new file mode 100644 index 0000000..ee09fda --- /dev/null +++ b/admin-v2/ai-compliance-sdk/internal/db/academy_store.go @@ -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 +} diff --git a/admin-v2/ai-compliance-sdk/internal/db/migrations/002_create_academy_tables.sql b/admin-v2/ai-compliance-sdk/internal/db/migrations/002_create_academy_tables.sql new file mode 100644 index 0000000..05df68d --- /dev/null +++ b/admin-v2/ai-compliance-sdk/internal/db/migrations/002_create_academy_tables.sql @@ -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'; diff --git a/admin-v2/ai-compliance-sdk/internal/llm/service.go b/admin-v2/ai-compliance-sdk/internal/llm/service.go index 61a78e0..d640ac3 100644 --- a/admin-v2/ai-compliance-sdk/internal/llm/service.go +++ b/admin-v2/ai-compliance-sdk/internal/llm/service.go @@ -45,7 +45,7 @@ func (s *Service) GenerateDSFA(ctx context.Context, context map[string]interface } // Build prompt with context and RAG sources - prompt := s.buildDSFAPrompt(context, ragSources) + _ = s.buildDSFAPrompt(context, ragSources) // In production, this would call the Anthropic API // response, err := s.callAnthropicAPI(ctx, prompt) diff --git a/admin-v2/ai-compliance-sdk/internal/rag/service.go b/admin-v2/ai-compliance-sdk/internal/rag/service.go index 1366094..5d7e5d1 100644 --- a/admin-v2/ai-compliance-sdk/internal/rag/service.go +++ b/admin-v2/ai-compliance-sdk/internal/rag/service.go @@ -88,7 +88,7 @@ func (s *Service) getMockSearchResults(query string, topK int) []SearchResult { // DSGVO Articles { ID: "dsgvo-art-5", - Content: "Art. 5 DSGVO - Grundsätze für die Verarbeitung personenbezogener Daten\n\n(1) Personenbezogene Daten müssen:\na) auf rechtmäßige Weise, nach Treu und Glauben und in einer für die betroffene Person nachvollziehbaren Weise verarbeitet werden („Rechtmäßigkeit, Verarbeitung nach Treu und Glauben, Transparenz");\nb) für festgelegte, eindeutige und legitime Zwecke erhoben werden und dürfen nicht in einer mit diesen Zwecken nicht zu vereinbarenden Weise weiterverarbeitet werden („Zweckbindung");\nc) dem Zweck angemessen und erheblich sowie auf das für die Zwecke der Verarbeitung notwendige Maß beschränkt sein („Datenminimierung");", + Content: "Art. 5 DSGVO - Grundsaetze fuer die Verarbeitung personenbezogener Daten\n\n(1) Personenbezogene Daten muessen:\na) auf rechtmaessige Weise, nach Treu und Glauben und in einer fuer die betroffene Person nachvollziehbaren Weise verarbeitet werden (Rechtmaessigkeit, Verarbeitung nach Treu und Glauben, Transparenz);\nb) fuer festgelegte, eindeutige und legitime Zwecke erhoben werden und duerfen nicht in einer mit diesen Zwecken nicht zu vereinbarenden Weise weiterverarbeitet werden (Zweckbindung);\nc) dem Zweck angemessen und erheblich sowie auf das fuer die Zwecke der Verarbeitung notwendige Mass beschraenkt sein (Datenminimierung);", Source: "DSGVO", Score: 0.95, Metadata: map[string]string{ diff --git a/admin-v2/app/(sdk)/sdk/academy/[id]/page.tsx b/admin-v2/app/(sdk)/sdk/academy/[id]/page.tsx new file mode 100644 index 0000000..548ad65 --- /dev/null +++ b/admin-v2/app/(sdk)/sdk/academy/[id]/page.tsx @@ -0,0 +1,517 @@ +'use client' + +import React, { useState, useEffect, useMemo } from 'react' +import { useParams, useRouter } from 'next/navigation' +import Link from 'next/link' +import { + Course, + Lesson, + Enrollment, + QuizQuestion, + COURSE_CATEGORY_INFO, + ENROLLMENT_STATUS_INFO, + isEnrollmentOverdue, + getDaysUntilDeadline +} from '@/lib/sdk/academy/types' +import { + fetchCourse, + fetchEnrollments, + deleteCourse, + submitQuiz, + generateVideos, + getVideoStatus +} from '@/lib/sdk/academy/api' + +type TabId = 'overview' | 'lessons' | 'enrollments' | 'videos' + +export default function CourseDetailPage() { + const params = useParams() + const router = useRouter() + const courseId = params.id as string + + const [course, setCourse] = useState(null) + const [enrollments, setEnrollments] = useState([]) + const [activeTab, setActiveTab] = useState('overview') + const [isLoading, setIsLoading] = useState(true) + const [selectedLesson, setSelectedLesson] = useState(null) + const [quizAnswers, setQuizAnswers] = useState>({}) + const [quizResult, setQuizResult] = useState(null) + const [isSubmittingQuiz, setIsSubmittingQuiz] = useState(false) + const [videoStatus, setVideoStatus] = useState(null) + const [isGeneratingVideos, setIsGeneratingVideos] = useState(false) + + useEffect(() => { + const loadData = async () => { + setIsLoading(true) + try { + const [courseData, enrollmentData] = await Promise.all([ + fetchCourse(courseId).catch(() => null), + fetchEnrollments(courseId).catch(() => []) + ]) + setCourse(courseData) + setEnrollments(Array.isArray(enrollmentData) ? enrollmentData : []) + if (courseData && courseData.lessons && courseData.lessons.length > 0) { + setSelectedLesson(courseData.lessons[0]) + } + } catch (error) { + console.error('Failed to load course:', error) + } finally { + setIsLoading(false) + } + } + loadData() + }, [courseId]) + + const handleDeleteCourse = async () => { + if (!confirm('Sind Sie sicher, dass Sie diesen Kurs loeschen moechten? Diese Aktion kann nicht rueckgaengig gemacht werden.')) return + try { + await deleteCourse(courseId) + router.push('/sdk/academy') + } catch (error) { + console.error('Failed to delete course:', error) + } + } + + const handleSubmitQuiz = async () => { + if (!selectedLesson) return + const questions = selectedLesson.quizQuestions || [] + const answers = questions.map((q: QuizQuestion) => quizAnswers[q.id] ?? -1) + + setIsSubmittingQuiz(true) + try { + const result = await submitQuiz(selectedLesson.id, { answers }) + setQuizResult(result) + } catch (error: any) { + console.error('Quiz submission failed:', error) + setQuizResult({ error: error.message || 'Fehler bei der Auswertung' }) + } finally { + setIsSubmittingQuiz(false) + } + } + + const handleGenerateVideos = async () => { + setIsGeneratingVideos(true) + try { + const status = await generateVideos(courseId) + setVideoStatus(status) + } catch (error) { + console.error('Video generation failed:', error) + } finally { + setIsGeneratingVideos(false) + } + } + + const handleCheckVideoStatus = async () => { + try { + const status = await getVideoStatus(courseId) + setVideoStatus(status) + } catch (error) { + console.error('Failed to check video status:', error) + } + } + + if (isLoading) { + return ( +
+ + + + +
+ ) + } + + if (!course) { + return ( +
+

Kurs nicht gefunden

+ + Zurueck zur Uebersicht + +
+ ) + } + + const categoryInfo = COURSE_CATEGORY_INFO[course.category] || COURSE_CATEGORY_INFO['custom'] + const sortedLessons = [...(course.lessons || [])].sort((a, b) => a.order - b.order) + const completedEnrollments = enrollments.filter(e => e.status === 'completed').length + const overdueEnrollments = enrollments.filter(e => isEnrollmentOverdue(e)).length + + return ( +
+ {/* Header */} +
+
+ + + + + +
+
+ + {categoryInfo.label} + + + {course.status === 'published' ? 'Veroeffentlicht' : 'Entwurf'} + +
+

{course.title}

+

{course.description}

+
+
+
+ +
+
+ + {/* Stats Row */} +
+
+
Lektionen
+
{sortedLessons.length}
+
+
+
Dauer
+
{course.durationMinutes} Min.
+
+
+
Teilnehmer
+
{enrollments.length}
+
+
+
Abgeschlossen
+
{completedEnrollments}
+
+
+ + {/* Tabs */} +
+ +
+ + {/* Overview Tab */} + {activeTab === 'overview' && ( +
+
+

Kurs-Details

+
+
Bestehensgrenze
{course.passingScore}%
+
Pflicht fuer
{course.requiredForRoles?.join(', ') || 'Alle'}
+
Erstellt am
{new Date(course.createdAt).toLocaleDateString('de-DE')}
+
Aktualisiert am
{new Date(course.updatedAt).toLocaleDateString('de-DE')}
+
+
+ + {/* Lesson List Preview */} +
+

Lektionen ({sortedLessons.length})

+
+ {sortedLessons.map((lesson, i) => ( +
+
+ {i + 1} +
+
+
{lesson.title}
+
{lesson.durationMinutes} Min. | {lesson.type === 'video' ? 'Video' : lesson.type === 'quiz' ? 'Quiz' : 'Text'}
+
+ + {lesson.type === 'quiz' ? 'Quiz' : lesson.type === 'video' ? 'Video' : 'Text'} + +
+ ))} +
+
+
+ )} + + {/* Lessons Tab - with content viewer and quiz player */} + {activeTab === 'lessons' && ( +
+ {/* Lesson Navigation */} +
+

Lektionen

+
+ {sortedLessons.map((lesson, i) => ( + + ))} +
+
+ + {/* Lesson Content */} +
+ {selectedLesson ? ( +
+
+

{selectedLesson.title}

+ + {selectedLesson.type === 'quiz' ? 'Quiz' : selectedLesson.type === 'video' ? 'Video' : 'Text'} + +
+ + {/* Video Player */} + {selectedLesson.type === 'video' && selectedLesson.videoUrl && ( +
+
+ )} + + {/* Text Content */} + {(selectedLesson.type === 'text' || selectedLesson.type === 'video') && selectedLesson.contentMarkdown && ( +
+
+ {selectedLesson.contentMarkdown.split('\n').map((line, i) => { + if (line.startsWith('# ')) return

{line.slice(2)}

+ if (line.startsWith('## ')) return

{line.slice(3)}

+ if (line.startsWith('### ')) return

{line.slice(4)}

+ if (line.startsWith('- **')) { + const parts = line.slice(2).split('**') + return
  • {parts[1]}{parts[2] || ''}
  • + } + if (line.startsWith('- ')) return
  • {line.slice(2)}
  • + if (line.trim() === '') return
    + return

    {line}

    + })} +
    +
    + )} + + {/* Quiz Player */} + {selectedLesson.type === 'quiz' && selectedLesson.quizQuestions && ( +
    + {selectedLesson.quizQuestions.map((q: QuizQuestion, qi: number) => ( +
    +

    Frage {qi + 1}: {q.question}

    +
    + {q.options.map((option: string, oi: number) => { + const isSelected = quizAnswers[q.id] === oi + const showResult = quizResult && !quizResult.error + const isCorrect = showResult && quizResult.results?.[qi]?.correct + const wasSelected = showResult && isSelected + + let bgClass = 'bg-white border-gray-200 hover:border-purple-300' + if (isSelected && !showResult) bgClass = 'bg-purple-50 border-purple-500' + if (showResult && oi === q.correctOptionIndex) bgClass = 'bg-green-50 border-green-500' + if (showResult && wasSelected && !isCorrect) bgClass = 'bg-red-50 border-red-500' + + return ( + + ) + })} +
    + {quizResult && !quizResult.error && quizResult.results?.[qi] && ( +
    + {quizResult.results[qi].correct ? 'Richtig!' : 'Falsch.'} {q.explanation} +
    + )} +
    + ))} + + {/* Quiz Submit / Result */} + {!quizResult ? ( + + ) : quizResult.error ? ( +
    {quizResult.error}
    + ) : ( +
    +
    + {quizResult.score}% +
    +
    + {quizResult.passed ? 'Bestanden!' : 'Nicht bestanden'} — {quizResult.correctAnswers}/{quizResult.totalQuestions} richtig +
    + +
    + )} +
    + )} +
    + ) : ( +

    Waehlen Sie eine Lektion aus.

    + )} +
    +
    + )} + + {/* Enrollments Tab */} + {activeTab === 'enrollments' && ( +
    + {overdueEnrollments > 0 && ( +
    + {overdueEnrollments} ueberfaellige Einschreibung(en) +
    + )} + {enrollments.length === 0 ? ( +
    +

    Noch keine Einschreibungen fuer diesen Kurs.

    +
    + ) : ( + enrollments.map(enrollment => { + const statusInfo = ENROLLMENT_STATUS_INFO[enrollment.status] + const overdue = isEnrollmentOverdue(enrollment) + const daysUntil = getDaysUntilDeadline(enrollment.deadline) + return ( +
    +
    +
    +
    + + {statusInfo?.label} + + {overdue && Ueberfaellig} +
    +
    {enrollment.userName}
    +
    {enrollment.userEmail}
    +
    +
    +
    {enrollment.progress}%
    +
    + {enrollment.status === 'completed' ? 'Abgeschlossen' : `${daysUntil > 0 ? daysUntil + ' Tage verbleibend' : Math.abs(daysUntil) + ' Tage ueberfaellig'}`} +
    +
    +
    +
    +
    +
    +
    + ) + }) + )} +
    + )} + + {/* Videos Tab */} + {activeTab === 'videos' && ( +
    +
    +
    +

    Video-Generierung

    +
    + + +
    +
    + +
    + Videos werden mit ElevenLabs (Stimme) und HeyGen (Avatar) generiert. + Konfigurieren Sie die API-Keys in den Umgebungsvariablen. +
    + + {videoStatus && ( +
    +
    + Gesamtstatus: + + {videoStatus.status} + +
    + {videoStatus.lessons?.map((ls: any) => ( +
    + Lektion {ls.lessonId.slice(-4)} + + {ls.status} + +
    + ))} +
    + )} + + {!videoStatus && ( +

    + Klicken Sie auf "Videos generieren" um den Prozess zu starten. +

    + )} +
    +
    + )} +
    + ) +} diff --git a/admin-v2/app/(sdk)/sdk/academy/new/page.tsx b/admin-v2/app/(sdk)/sdk/academy/new/page.tsx new file mode 100644 index 0000000..c76fee7 --- /dev/null +++ b/admin-v2/app/(sdk)/sdk/academy/new/page.tsx @@ -0,0 +1,385 @@ +'use client' + +import React, { useState } from 'react' +import { useRouter } from 'next/navigation' +import Link from 'next/link' +import { useSDK } from '@/lib/sdk' +import { + CourseCategory, + COURSE_CATEGORY_INFO, + CreateCourseRequest, + GenerateCourseRequest +} from '@/lib/sdk/academy/types' +import { createCourse, generateCourse } from '@/lib/sdk/academy/api' + +type CreationMode = 'manual' | 'ai' + +export default function NewCoursePage() { + const router = useRouter() + const [mode, setMode] = useState('ai') + const [isLoading, setIsLoading] = useState(false) + const [error, setError] = useState(null) + + // Manual form state + const [title, setTitle] = useState('') + const [description, setDescription] = useState('') + const [category, setCategory] = useState('dsgvo_basics') + const [duration, setDuration] = useState(60) + const [passingScore, setPassingScore] = useState(70) + + // AI generation state + const [topic, setTopic] = useState('') + const [targetGroup, setTargetGroup] = useState('Alle Mitarbeiter') + const [useRag, setUseRag] = useState(true) + + const handleManualCreate = async () => { + if (!title.trim()) { + setError('Bitte geben Sie einen Kurstitel ein.') + return + } + setIsLoading(true) + setError(null) + + try { + const tenantId = typeof window !== 'undefined' + ? localStorage.getItem('bp_tenant_id') || 'default-tenant' + : 'default-tenant' + + const result = await createCourse({ + tenantId, + title: title.trim(), + description: description.trim(), + category, + durationMinutes: duration, + passingScore, + requiredForRoles: ['all'] + } as any) + + // Navigate to the new course + if (result && (result as any).id) { + router.push(`/sdk/academy/${(result as any).id}`) + } else { + router.push('/sdk/academy') + } + } catch (err: any) { + setError(err.message || 'Fehler beim Erstellen des Kurses') + } finally { + setIsLoading(false) + } + } + + const handleAIGenerate = async () => { + if (!topic.trim()) { + setError('Bitte geben Sie ein Thema fuer den Kurs ein.') + return + } + setIsLoading(true) + setError(null) + + try { + const tenantId = typeof window !== 'undefined' + ? localStorage.getItem('bp_tenant_id') || 'default-tenant' + : 'default-tenant' + + const result = await generateCourse({ + tenantId, + topic: topic.trim(), + category, + targetGroup: targetGroup.trim(), + language: 'de', + useRag + }) + + if (result && result.course && result.course.id) { + router.push(`/sdk/academy/${result.course.id}`) + } else { + router.push('/sdk/academy') + } + } catch (err: any) { + setError(err.message || 'Fehler bei der KI-Generierung') + } finally { + setIsLoading(false) + } + } + + return ( +
    + {/* Header */} +
    + + + + + +
    +

    Neuen Kurs erstellen

    +

    + Erstellen Sie einen Compliance-Schulungskurs manuell oder lassen Sie ihn von der KI generieren. +

    +
    +
    + + {/* Mode Toggle */} +
    + + +
    + + {/* Error Message */} + {error && ( +
    + + + +

    {error}

    +
    + )} + + {/* AI Generation Form */} + {mode === 'ai' && ( +
    +
    + + + +
    +

    KI-generierter Kurs

    +

    + Die KI erstellt automatisch Lektionen, Inhalte und Quizfragen basierend auf dem gewaehlten Thema. + Optionaler RAG-Kontext aus relevanten Gesetzestexten wird einbezogen. +

    +
    +
    + + {/* Topic */} +
    + + setTopic(e.target.value)} + placeholder="z.B. DSGVO-Grundlagen fuer neue Mitarbeiter" + className="w-full px-4 py-3 border border-gray-300 rounded-xl focus:ring-2 focus:ring-purple-500 focus:border-purple-500 text-base" + /> +
    + + {/* Category */} +
    + +
    + {Object.entries(COURSE_CATEGORY_INFO).map(([cat, info]) => ( + + ))} +
    +
    + + {/* Target Group */} +
    + + setTargetGroup(e.target.value)} + placeholder="z.B. Alle Mitarbeiter, IT-Abteilung, Fuehrungskraefte" + className="w-full px-4 py-3 border border-gray-300 rounded-xl focus:ring-2 focus:ring-purple-500 focus:border-purple-500" + /> +
    + + {/* RAG Toggle */} +
    + +
    + RAG-Kontext verwenden +

    Relevante Gesetzestexte (DSGVO, AI Act, NIS2) einbeziehen

    +
    +
    + + {/* Submit */} +
    + + Abbrechen + + +
    +
    + )} + + {/* Manual Creation Form */} + {mode === 'manual' && ( +
    + {/* Title */} +
    + + setTitle(e.target.value)} + placeholder="z.B. DSGVO-Grundlagen fuer Mitarbeiter" + className="w-full px-4 py-3 border border-gray-300 rounded-xl focus:ring-2 focus:ring-purple-500 focus:border-purple-500 text-base" + /> +
    + + {/* Description */} +
    + +