403 lines
12 KiB
Go

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,
})
}