package main import ( "context" "errors" "net/http" "time" "github.com/go-chi/chi/v5" chimiddleware "github.com/go-chi/chi/v5/middleware" ) func (a *app) routes() http.Handler { r := chi.NewRouter() r.Use(chimiddleware.RequestID) r.Use(chimiddleware.RealIP) r.Use(chimiddleware.Logger) r.Use(chimiddleware.Recoverer) a.registerPublicRoutes(r) a.registerAuthRoutes(r) r.Get("/api/health", a.healthHandler) r.Route("/api", func(r chi.Router) { r.Use(a.authMiddleware) a.registerCoreAPIRoutes(r) a.registerEntitlementsRoutes(r) a.registerKeycloakThemeRoutes(r) a.registerLiveCourseRoutes(r) a.registerLearnerRoutes(r) a.registerFormsRoutes(r) a.registerSitemapMenuRoutes(r) a.registerNotificationRoutes(r) a.registerAdminRoutes(r) a.registerAssetRoutes(r) a.registerCommunityRoutes(r) a.registerBlogRoutes(r) }) r.NotFound(func(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusNotFound, "route not found") }) return withCORS(a.cfg.CorsOrigins, r) } func (a *app) healthHandler(w http.ResponseWriter, r *http.Request) { ctx, cancel := context.WithTimeout(r.Context(), 4*time.Second) defer cancel() postgresStatus := "up" if a.db == nil || a.db.Ping(ctx) != nil { postgresStatus = "down" } status := "ok" if postgresStatus != "up" { status = "degraded" } writeJSON(w, http.StatusOK, map[string]any{ "status": status, "service": "cms-backend", "timestamp": time.Now().UTC().Format(time.RFC3339), "deps": map[string]string{ "postgres": postgresStatus, }, }) } func (a *app) loginHandler(w http.ResponseWriter, r *http.Request) { writeRouteNotEnabled( w, "/auth/login", "local login is disabled", "use Keycloak OAuth authorization code flow from the frontend client", "docs/BACKLOG.md", ) } func (a *app) refreshHandler(w http.ResponseWriter, r *http.Request) { writeRouteNotEnabled( w, "/auth/refresh", "backend token refresh endpoint is disabled", "refresh is handled by Keycloak in the frontend oauth client", "docs/BACKLOG.md", ) } func (a *app) logoutHandler(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNoContent) } func writeRouteNotEnabled(w http.ResponseWriter, routePath, errMsg, detail, backlogRef string) { writeJSON(w, http.StatusNotImplemented, map[string]any{ "error": errMsg, "message": detail, "route": routePath, "status": "not_enabled", "backlogRef": backlogRef, }) } func (a *app) meHandler(w http.ResponseWriter, r *http.Request) { claims, _ := r.Context().Value(userClaimsKey).(*accessClaims) if claims == nil { writeError(w, http.StatusUnauthorized, "no authenticated user") return } role := "learner" if hasRole(claims, "admin") { role = "admin" } else if hasAnyRole(claims, "platform_owner", "platform_admin") { role = "platform_admin" } else if hasRole(claims, "instructor") { role = "instructor" } writeJSON(w, http.StatusOK, map[string]any{ "id": resolveClaimsUserID(claims), "email": claims.Email, "username": claims.PreferredUsername, "role": role, "roles": claims.RealmAccess.Roles, "tenantId": resolveClaimsTenantID(claims), }) } func (a *app) listPages(w http.ResponseWriter, r *http.Request) { a.listCollectionWithRequest("pages", w, r) } func (a *app) getPage(w http.ResponseWriter, r *http.Request) { a.getCollection("pages", w, r) } func (a *app) deletePage(w http.ResponseWriter, r *http.Request) { a.deleteCollection("pages", w, r) } func (a *app) listDataSources(w http.ResponseWriter, r *http.Request) { a.listCollectionWithRequest("datasources", w, r) } func (a *app) createDataSource(w http.ResponseWriter, r *http.Request) { a.createCollection("datasources", w, r) } func (a *app) getDataSource(w http.ResponseWriter, r *http.Request) { a.getCollection("datasources", w, r) } func (a *app) patchDataSource(w http.ResponseWriter, r *http.Request) { a.patchCollection("datasources", w, r) } func (a *app) deleteDataSource(w http.ResponseWriter, r *http.Request) { a.deleteCollection("datasources", w, r) } func (a *app) listScormCourses(w http.ResponseWriter, r *http.Request) { a.listCollectionWithRequest("scorm_courses", w, r) } func (a *app) createScormCourse(w http.ResponseWriter, r *http.Request) { a.createCollection("scorm_courses", w, r) } func (a *app) getScormCourse(w http.ResponseWriter, r *http.Request) { a.getCollection("scorm_courses", w, r) } func (a *app) patchScormCourse(w http.ResponseWriter, r *http.Request) { a.patchCollection("scorm_courses", w, r) } func (a *app) deleteScormCourse(w http.ResponseWriter, r *http.Request) { a.deleteCollection("scorm_courses", w, r) } func (a *app) listUsers(w http.ResponseWriter, r *http.Request) { a.listCollectionWithRequest("users", w, r) } func (a *app) createUser(w http.ResponseWriter, r *http.Request) { a.createCollection("users", w, r) } func (a *app) getUser(w http.ResponseWriter, r *http.Request) { a.getCollection("users", w, r) } func (a *app) patchUser(w http.ResponseWriter, r *http.Request) { a.patchCollection("users", w, r) } func (a *app) deleteUser(w http.ResponseWriter, r *http.Request) { a.deleteCollection("users", w, r) } func (a *app) listEnrollments(w http.ResponseWriter, r *http.Request) { a.listCollectionWithRequest("enrollments", w, r) } func (a *app) createEnrollment(w http.ResponseWriter, r *http.Request) { a.createCollection("enrollments", w, r) } func (a *app) getEnrollment(w http.ResponseWriter, r *http.Request) { a.getCollection("enrollments", w, r) } func (a *app) patchEnrollment(w http.ResponseWriter, r *http.Request) { a.patchCollection("enrollments", w, r) } func (a *app) deleteEnrollment(w http.ResponseWriter, r *http.Request) { a.deleteCollection("enrollments", w, r) } func (a *app) listWebhooks(w http.ResponseWriter, r *http.Request) { a.listCollectionWithRequest("webhooks", w, r) } func (a *app) createWebhook(w http.ResponseWriter, r *http.Request) { a.createCollection("webhooks", w, r) } func (a *app) getWebhook(w http.ResponseWriter, r *http.Request) { a.getCollection("webhooks", w, r) } func (a *app) patchWebhook(w http.ResponseWriter, r *http.Request) { a.patchCollection("webhooks", w, r) } func (a *app) deleteWebhook(w http.ResponseWriter, r *http.Request) { a.deleteCollection("webhooks", w, r) } func (a *app) listCollection(name string, w http.ResponseWriter) { a.listCollectionWithRequest(name, w, nil) } func (a *app) listCollectionWithRequest(name string, w http.ResponseWriter, r *http.Request) { tenantID, enforceTenant, scopeErr := requestTenantScope(r, name) if scopeErr != nil { writeError(w, http.StatusForbidden, scopeErr.Error()) return } filter := Doc{} if enforceTenant { filter["tenantId"] = tenantID } docs, err := a.list(name, filter, Sort{{Key: "updatedAt", Value: -1}}) if err != nil { writeError(w, http.StatusInternalServerError, "failed to list resources") return } writeJSON(w, http.StatusOK, docs) } func (a *app) createCollection(name string, w http.ResponseWriter, r *http.Request) { var payload map[string]any if err := decodeJSON(r, &payload); err != nil { writeError(w, http.StatusBadRequest, "invalid payload") return } tenantID, enforceTenant, scopeErr := requestTenantScope(r, name) if scopeErr != nil { writeError(w, http.StatusForbidden, scopeErr.Error()) return } if enforceTenant { payload["tenantId"] = tenantID } doc, err := a.create(name, Doc(payload)) if err != nil { writeError(w, http.StatusInternalServerError, "failed to create resource") return } writeJSON(w, http.StatusCreated, doc) } func (a *app) getCollection(name string, w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") tenantID, enforceTenant, scopeErr := requestTenantScope(r, name) if scopeErr != nil { writeError(w, http.StatusForbidden, scopeErr.Error()) return } doc, err := a.getByIDScoped(name, id, tenantID, enforceTenant) if err != nil { if errors.Is(err, ErrNotFound) { writeError(w, http.StatusNotFound, "resource not found") return } writeError(w, http.StatusInternalServerError, "failed to load resource") return } writeJSON(w, http.StatusOK, doc) } func (a *app) patchCollection(name string, w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") var patch map[string]any if err := decodeJSON(r, &patch); err != nil { writeError(w, http.StatusBadRequest, "invalid patch payload") return } tenantID, enforceTenant, scopeErr := requestTenantScope(r, name) if scopeErr != nil { writeError(w, http.StatusForbidden, scopeErr.Error()) return } if enforceTenant { delete(patch, "tenantId") } doc, err := a.patchWithUnsetScoped(name, id, Doc(patch), nil, tenantID, enforceTenant) if err != nil { if errors.Is(err, ErrNotFound) { writeError(w, http.StatusNotFound, "resource not found") return } writeError(w, http.StatusInternalServerError, "failed to update resource") return } writeJSON(w, http.StatusOK, doc) } func (a *app) deleteCollection(name string, w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") tenantID, enforceTenant, scopeErr := requestTenantScope(r, name) if scopeErr != nil { writeError(w, http.StatusForbidden, scopeErr.Error()) return } if err := a.deleteByIDScoped(name, id, tenantID, enforceTenant); err != nil { if errors.Is(err, ErrNotFound) { writeError(w, http.StatusNotFound, "resource not found") return } writeError(w, http.StatusInternalServerError, "failed to delete resource") return } w.WriteHeader(http.StatusNoContent) } func (a *app) exportScormCourse(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") if _, err := a.getByID("scorm_courses", id); err != nil { if errors.Is(err, ErrNotFound) { writeError(w, http.StatusNotFound, "course not found") return } writeError(w, http.StatusInternalServerError, "failed to load course") return } var body struct { Version string `json:"version"` } if err := decodeJSON(r, &body); err != nil { writeError(w, http.StatusBadRequest, "invalid export payload") return } if body.Version == "" { body.Version = "scorm12" } exportDoc, err := a.createScormExport(id, body.Version) if err != nil { writeError(w, http.StatusInternalServerError, "failed to create export") return } exportID, _ := exportDoc["id"].(string) writeJSON(w, http.StatusOK, map[string]any{ "downloadUrl": "/api/scorm/" + id + "/exports/" + exportID, "expiresAt": time.Now().Add(1 * time.Hour).UTC().Format(time.RFC3339), }) } func (a *app) listScormExports(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") docs, err := a.list("scorm_exports", Doc{"courseId": id}, Sort{{Key: "createdAt", Value: -1}}) if err != nil { writeError(w, http.StatusInternalServerError, "failed to list exports") return } writeJSON(w, http.StatusOK, docs) } func (a *app) analyticsHandler(w http.ResponseWriter, r *http.Request) { enrollments, err := a.list("enrollments", Doc{}, nil) if err != nil { writeError(w, http.StatusInternalServerError, "failed to compute analytics") return } users, err := a.list("users", Doc{}, nil) if err != nil { writeError(w, http.StatusInternalServerError, "failed to compute analytics") return } completed := 0 totalScore := 0.0 scored := 0 for _, enrollment := range enrollments { if status, _ := enrollment["status"].(string); status == "completed" { completed++ } switch score := enrollment["score"].(type) { case float64: totalScore += score scored++ case int32: totalScore += float64(score) scored++ case int64: totalScore += float64(score) scored++ } } averageScore := 0.0 if scored > 0 { averageScore = totalScore / float64(scored) } writeJSON(w, http.StatusOK, map[string]any{ "totalUsers": len(users), "totalEnrollments": len(enrollments), "completions": completed, "averageScore": averageScore, }) }