diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..bf4077a --- /dev/null +++ b/.dockerignore @@ -0,0 +1,14 @@ +.git +.gitignore +.github +**/node_modules +**/dist +**/.cache +**/.vite +**/coverage +**/playwright-report +**/test-results +**/.env +**/.env.* +!**/.env.example +*.log diff --git a/Backend/cmd/api/admin_settings.go b/Backend/cmd/api/admin_settings.go new file mode 100644 index 0000000..9a03491 --- /dev/null +++ b/Backend/cmd/api/admin_settings.go @@ -0,0 +1,357 @@ +package main + +import ( + "errors" + "net/http" + "net/url" + "strings" +) + +const adminSettingsID = "default" + +func (a *app) settingsAdminMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + claims, _ := r.Context().Value(userClaimsKey).(*accessClaims) + if claims == nil { + writeError(w, http.StatusUnauthorized, "no authenticated user") + return + } + if !hasRole(claims, "admin") { + writeError(w, http.StatusForbidden, "insufficient role for admin settings") + return + } + next.ServeHTTP(w, r) + }) +} + +func (a *app) getAdminSettings(w http.ResponseWriter, r *http.Request) { + doc := defaultAdminSettingsDocument() + if a.db != nil { + stored, err := a.getByID("platform_settings", adminSettingsID) + if err == nil { + doc = mergeAdminSettingsDocument(doc, stored) + } else if !errors.Is(err, ErrNotFound) { + writeError(w, http.StatusInternalServerError, "failed to load admin settings") + return + } + } + + writeJSON(w, http.StatusOK, Doc{ + "integrations": adminIntegrationStatus(a.cfg), + "settings": adminSettingsView(doc), + "environment": Doc{ + "notificationProvider": a.cfg.NotificationProvider, + "keycloakAuthDisabled": a.cfg.AuthDisabled, + "keycloakThemeDeploy": a.cfg.KeycloakThemeDeployEnabled, + "databaseConnected": a.db != nil, + }, + }) +} + +func (a *app) patchAdminSettings(w http.ResponseWriter, r *http.Request) { + claims, _ := r.Context().Value(userClaimsKey).(*accessClaims) + requestMetadata := map[string]any{"operation": "patch_admin_settings"} + + if a.db == nil { + a.recordAuditEvent(r.Context(), claims, "settings.patch", "platform_settings", auditResultFailed, map[string]any{ + "reason": "database_not_configured", + }) + writeError(w, http.StatusServiceUnavailable, "database not configured") + return + } + + var payload map[string]any + if err := decodeJSON(r, &payload); err != nil { + a.recordAuditEvent(r.Context(), claims, "settings.patch", "platform_settings", auditResultFailed, map[string]any{ + "reason": "invalid_patch_payload", + }) + writeError(w, http.StatusBadRequest, "invalid patch payload") + return + } + requestMetadata["payloadSections"] = []string{} + if _, ok := payload["platformIdentity"]; ok { + requestMetadata["payloadSections"] = append(requestMetadata["payloadSections"].([]string), "platformIdentity") + } + if _, ok := payload["seoDefaults"]; ok { + requestMetadata["payloadSections"] = append(requestMetadata["payloadSections"].([]string), "seoDefaults") + } + if _, ok := payload["consentLinks"]; ok { + requestMetadata["payloadSections"] = append(requestMetadata["payloadSections"].([]string), "consentLinks") + } + + patchDoc, err := parseAdminSettingsPayload(payload) + if err != nil { + a.recordAuditEvent(r.Context(), claims, "settings.patch", "platform_settings", auditResultFailed, map[string]any{ + "reason": "validation_error", + "message": err.Error(), + }) + writeError(w, http.StatusBadRequest, err.Error()) + return + } + + if len(patchDoc) == 0 { + a.recordAuditEvent(r.Context(), claims, "settings.patch", "platform_settings", auditResultFailed, map[string]any{ + "reason": "no_changes", + }) + writeError(w, http.StatusBadRequest, "no settings changes provided") + return + } + + existing, err := a.getByID("platform_settings", adminSettingsID) + if err != nil { + if errors.Is(err, ErrNotFound) { + base := defaultAdminSettingsDocument() + merged := mergeAdminSettingsDocument(base, patchDoc) + merged["id"] = adminSettingsID + if _, createErr := a.create("platform_settings", merged); createErr != nil { + a.recordAuditEvent(r.Context(), claims, "settings.patch", "platform_settings", auditResultFailed, map[string]any{ + "reason": "create_failed", + }) + writeError(w, http.StatusInternalServerError, "failed to persist admin settings") + return + } + a.recordAuditEvent(r.Context(), claims, "settings.patch", "platform_settings", auditResultSuccess, requestMetadata) + writeJSON(w, http.StatusOK, Doc{"settings": adminSettingsView(merged)}) + return + } + a.recordAuditEvent(r.Context(), claims, "settings.patch", "platform_settings", auditResultFailed, map[string]any{ + "reason": "load_failed", + }) + writeError(w, http.StatusInternalServerError, "failed to persist admin settings") + return + } + + merged := mergeAdminSettingsDocument(existing, patchDoc) + updated, err := a.patch("platform_settings", adminSettingsID, Doc{ + "platformIdentity": merged["platformIdentity"], + "seoDefaults": merged["seoDefaults"], + "consentLinks": merged["consentLinks"], + }) + if err != nil { + a.recordAuditEvent(r.Context(), claims, "settings.patch", "platform_settings", auditResultFailed, map[string]any{ + "reason": "patch_failed", + }) + writeError(w, http.StatusInternalServerError, "failed to persist admin settings") + return + } + + a.recordAuditEvent(r.Context(), claims, "settings.patch", "platform_settings", auditResultSuccess, requestMetadata) + writeJSON(w, http.StatusOK, Doc{"settings": adminSettingsView(updated)}) +} + +func parseAdminSettingsPayload(payload map[string]any) (Doc, error) { + out := Doc{} + + if section, ok := payload["platformIdentity"]; ok { + normalized, err := parseAdminSettingsStringSection(section, map[string]bool{ + "siteName": false, + "supportEmail": false, + }) + if err != nil { + return nil, err + } + out["platformIdentity"] = normalized + } + + if section, ok := payload["seoDefaults"]; ok { + normalized, err := parseAdminSettingsStringSection(section, map[string]bool{ + "defaultTitle": false, + "defaultDescription": false, + "canonicalBaseUrl": true, + }) + if err != nil { + return nil, err + } + out["seoDefaults"] = normalized + } + + if section, ok := payload["consentLinks"]; ok { + normalized, err := parseAdminSettingsStringSection(section, map[string]bool{ + "privacyPolicyUrl": true, + "termsOfServiceUrl": true, + "cookiePolicyUrl": true, + }) + if err != nil { + return nil, err + } + out["consentLinks"] = normalized + } + + return out, nil +} + +func parseAdminSettingsStringSection(raw any, fields map[string]bool) (Doc, error) { + section, ok := raw.(map[string]any) + if !ok { + return nil, errors.New("settings sections must be objects") + } + + out := Doc{} + for key, enforceURL := range fields { + value, exists := section[key] + if !exists { + continue + } + stringValue, ok := value.(string) + if !ok { + return nil, errors.New(key + " must be a string") + } + normalized := strings.TrimSpace(stringValue) + if enforceURL { + valid, err := normalizeOptionalHTTPURL(normalized) + if err != nil { + return nil, errors.New(key + " must be an absolute http(s) URL") + } + normalized = valid + } + out[key] = normalized + } + + return out, nil +} + +func normalizeOptionalHTTPURL(raw string) (string, error) { + trimmed := strings.TrimSpace(raw) + if trimmed == "" { + return "", nil + } + parsed, err := url.Parse(trimmed) + if err != nil || parsed.Scheme == "" || parsed.Host == "" { + return "", errors.New("invalid URL") + } + if parsed.Scheme != "http" && parsed.Scheme != "https" { + return "", errors.New("invalid scheme") + } + return parsed.String(), nil +} + +func defaultAdminSettingsDocument() Doc { + return Doc{ + "id": adminSettingsID, + "platformIdentity": Doc{ + "siteName": "", + "supportEmail": "", + }, + "seoDefaults": Doc{ + "defaultTitle": "", + "defaultDescription": "", + "canonicalBaseUrl": "", + }, + "consentLinks": Doc{ + "privacyPolicyUrl": "", + "termsOfServiceUrl": "", + "cookiePolicyUrl": "", + }, + } +} + +func mergeAdminSettingsDocument(base, incoming Doc) Doc { + out := defaultAdminSettingsDocument() + for _, key := range []string{"platformIdentity", "seoDefaults", "consentLinks"} { + out[key] = cloneStringMap(out[key]) + } + applyAdminSettingsSection(out, base) + applyAdminSettingsSection(out, incoming) + return out +} + +func applyAdminSettingsSection(target, source Doc) { + for _, key := range []string{"platformIdentity", "seoDefaults", "consentLinks"} { + incoming, ok := source[key] + if !ok { + continue + } + incomingMap, ok := incoming.(Doc) + if !ok { + if genericMap, okAny := incoming.(map[string]any); okAny { + incomingMap = Doc(genericMap) + } else { + continue + } + } + current := cloneStringMap(target[key]) + for field, value := range incomingMap { + strValue, ok := value.(string) + if ok { + current[field] = strValue + } + } + target[key] = current + } +} + +func cloneStringMap(raw any) Doc { + if raw == nil { + return Doc{} + } + if value, ok := raw.(Doc); ok { + copy := Doc{} + for key, item := range value { + if stringItem, ok := item.(string); ok { + copy[key] = stringItem + } + } + return copy + } + if value, ok := raw.(map[string]any); ok { + copy := Doc{} + for key, item := range value { + if stringItem, ok := item.(string); ok { + copy[key] = stringItem + } + } + return copy + } + return Doc{} +} + +func adminSettingsView(doc Doc) Doc { + identity := cloneStringMap(doc["platformIdentity"]) + seo := cloneStringMap(doc["seoDefaults"]) + consent := cloneStringMap(doc["consentLinks"]) + return Doc{ + "platformIdentity": identity, + "seoDefaults": seo, + "consentLinks": consent, + } +} + +func adminIntegrationStatus(cfg config) Doc { + sendgridConfigured := cfg.NotificationProvider == "sendgrid" && strings.TrimSpace(cfg.SendGridAPIKey) != "" && strings.TrimSpace(cfg.SendGridFromEmail) != "" + stripeConfigured := strings.TrimSpace(cfg.StripeSecretKey) != "" && strings.TrimSpace(cfg.StripeWebhookSecret) != "" + minioConfigured := strings.TrimSpace(cfg.MinIOEndpoint) != "" && strings.TrimSpace(cfg.MinIOBucket) != "" && strings.TrimSpace(cfg.MinIOAccessKey) != "" && strings.TrimSpace(cfg.MinIOSecretKey) != "" + keycloakConfigured := strings.TrimSpace(cfg.KeycloakIssuer) != "" && strings.TrimSpace(cfg.KeycloakAudience) != "" + + return Doc{ + "sendgrid": Doc{ + "configured": sendgridConfigured, + "provider": cfg.NotificationProvider, + "fromEmailSet": strings.TrimSpace(cfg.SendGridFromEmail) != "", + "apiBaseURL": cfg.SendGridAPIBaseURL, + "setupInstruction": "Set NOTIFICATION_PROVIDER=sendgrid and configure SENDGRID_API_KEY and SENDGRID_FROM_EMAIL.", + }, + "stripe": Doc{ + "configured": stripeConfigured, + "apiBaseURL": cfg.StripeAPIBaseURL, + "priceIDsConfigured": len(cfg.StripeAllowedPriceIDs), + "setupInstruction": "Configure STRIPE_SECRET_KEY and STRIPE_WEBHOOK_SECRET, then set STRIPE_ALLOWED_PRICE_IDS.", + }, + "minio": Doc{ + "configured": minioConfigured, + "endpoint": cfg.MinIOEndpoint, + "bucket": cfg.MinIOBucket, + "setupInstruction": "Configure MINIO_ENDPOINT, MINIO_BUCKET, MINIO_ACCESS_KEY, and MINIO_SECRET_KEY.", + }, + "keycloak": Doc{ + "configured": keycloakConfigured, + "issuer": cfg.KeycloakIssuer, + "audience": cfg.KeycloakAudience, + "setupInstruction": "Set KEYCLOAK_ISSUER and KEYCLOAK_AUDIENCE, and disable AUTH_DISABLED in secured environments.", + }, + "assetEncryption": Doc{ + "configured": cfg.AssetEncryptionEnabled, + "enabled": cfg.AssetEncryptionEnabled, + "setupInstruction": "Set ASSET_ENCRYPTION_ENABLED=true to enforce encrypted asset handling.", + }, + } +} diff --git a/Backend/cmd/api/api_models.go b/Backend/cmd/api/api_models.go new file mode 100644 index 0000000..2dff409 --- /dev/null +++ b/Backend/cmd/api/api_models.go @@ -0,0 +1,75 @@ +package main + +import ( + "encoding/json" +) + +type dynamicObjectRequest struct { + Fields map[string]any +} + +func (r *dynamicObjectRequest) UnmarshalJSON(data []byte) error { + var payload map[string]any + if err := json.Unmarshal(data, &payload); err != nil { + return err + } + r.Fields = payload + return nil +} + +func (r dynamicObjectRequest) Doc() Doc { + if r.Fields == nil { + return Doc{} + } + return Doc(r.Fields) +} + +type formUpsertRequest struct { + dynamicObjectRequest +} + +type liveCourseUpsertRequest struct { + dynamicObjectRequest +} + +type liveCourseCohortUpsertRequest struct { + dynamicObjectRequest +} + +type liveCourseSessionUpsertRequest struct { + dynamicObjectRequest +} + +type liveCourseEnrollmentUpsertRequest struct { + dynamicObjectRequest +} + +type liveCourseAttendanceUpsertRequest struct { + dynamicObjectRequest +} + +type publicFormResponse struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + Schema any `json:"schema"` +} + +type publicFormSubmissionCreateResponse struct { + ID string `json:"id"` + FormID string `json:"formId"` + SubmittedAt int64 `json:"submittedAt"` +} + +type learnerLiveCourseJoinResponse struct { + Allowed bool `json:"allowed"` + CourseID string `json:"courseId"` + SessionID string `json:"sessionId"` + JoinURL string `json:"joinUrl"` +} + +type billingWebhookResponse struct { + Status string `json:"status"` + EventID string `json:"eventId"` + Message string `json:"message"` +} diff --git a/Backend/cmd/api/app.go b/Backend/cmd/api/app.go new file mode 100644 index 0000000..3a8f570 --- /dev/null +++ b/Backend/cmd/api/app.go @@ -0,0 +1,59 @@ +package main + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/coreos/go-oidc/v3/oidc" +) + +type app struct { + cfg config + db *pgDatabase + oidcVerifier *oidc.IDTokenVerifier + notificationProvider notificationProvider + stripeService stripeService + billingCustomerEnsurer func(string, string) (Doc, error) + stripeWebhookVerifier func([]byte, string) (stripeWebhookEvent, error) + pageSlugInUse func(string, string) (bool, error) +} + +func newApp(cfg config) (*app, error) { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + db, err := openPostgres(ctx, cfg.DatabaseURL) + if err != nil { + return nil, fmt.Errorf("connect postgres: %w", err) + } + + var verifier *oidc.IDTokenVerifier + if !cfg.AuthDisabled { + oidcCtx, oidcCancel := context.WithTimeout(context.Background(), 10*time.Second) + defer oidcCancel() + + keySet := oidc.NewRemoteKeySet(oidcCtx, strings.TrimRight(cfg.KeycloakIssuerInternal, "/")+"/protocol/openid-connect/certs") + verifier = oidc.NewVerifier(cfg.KeycloakIssuer, keySet, &oidc.Config{ + // Keycloak access tokens commonly identify the requesting public client + // through azp while aud contains resource servers. Verify issuer/signature/ + // expiry here, then enforce the configured client/audience in middleware. + SkipClientIDCheck: true, + }) + } + + return &app{ + cfg: cfg, + db: db, + oidcVerifier: verifier, + notificationProvider: newNotificationProvider(cfg), + stripeService: newStripeService(cfg), + }, nil +} + +func (a *app) close() { + if a.db != nil { + a.db.Close() + } +} diff --git a/Backend/cmd/api/assets.go b/Backend/cmd/api/assets.go new file mode 100644 index 0000000..bedd04c --- /dev/null +++ b/Backend/cmd/api/assets.go @@ -0,0 +1,398 @@ +package main + +import ( + "errors" + "net/http" + "strings" + "time" + + "github.com/go-chi/chi/v5" +) + +var ( + allowedAssetStatuses = map[string]struct{}{ + "active": {}, + "archived": {}, + "quarantined": {}, + "disabled": {}, + "deleted": {}, + } + allowedAssetVisibility = map[string]struct{}{ + "public": {}, + "authenticated": {}, + "enrolled": {}, + "entitled": {}, + "instructor": {}, + "admin": {}, + "private": {}, + } + allowedAssetProtectionLevels = map[string]struct{}{ + "none": {}, + "standard": {}, + "encrypted": {}, + "watermarked": {}, + "restricted": {}, + } +) + +func (a *app) assetsAdminMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + claims, _ := r.Context().Value(userClaimsKey).(*accessClaims) + if claims == nil { + writeError(w, http.StatusUnauthorized, "no authenticated user") + return + } + if !hasRole(claims, "admin") { + writeError(w, http.StatusForbidden, "insufficient role for asset management") + return + } + next.ServeHTTP(w, r) + }) +} + +func (a *app) listAssets(w http.ResponseWriter, r *http.Request) { + if a.db == nil { + writeError(w, http.StatusServiceUnavailable, "database not configured") + return + } + + filter := Doc{} + if status := strings.TrimSpace(r.URL.Query().Get("status")); status != "" { + filter["status"] = status + } + if visibility := strings.TrimSpace(r.URL.Query().Get("visibility")); visibility != "" { + filter["visibility"] = visibility + } + + docs, err := a.list("assets", filter, Sort{{Key: "updatedAt", Value: -1}}) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to list assets") + return + } + writeJSON(w, http.StatusOK, docs) +} + +func (a *app) createAsset(w http.ResponseWriter, r *http.Request) { + claims, _ := r.Context().Value(userClaimsKey).(*accessClaims) + if a.db == nil { + writeError(w, http.StatusServiceUnavailable, "database not configured") + return + } + + var payload map[string]any + if err := decodeJSON(r, &payload); err != nil { + writeError(w, http.StatusBadRequest, "invalid payload") + return + } + + doc, err := prepareAssetPayload(Doc(payload), false) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + + doc, err = a.create("assets", doc) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to create resource") + return + } + + a.recordAuditEvent(r.Context(), claims, "assets.create", "assets", auditResultSuccess, map[string]any{ + "assetId": doc["id"], + "status": doc["status"], + }) + writeJSON(w, http.StatusCreated, doc) +} + +func (a *app) getAsset(w http.ResponseWriter, r *http.Request) { + a.getCollection("assets", w, r) +} + +func (a *app) patchAsset(w http.ResponseWriter, r *http.Request) { + claims, _ := r.Context().Value(userClaimsKey).(*accessClaims) + if a.db == nil { + writeError(w, http.StatusServiceUnavailable, "database not configured") + return + } + + id := chi.URLParam(r, "id") + var payload map[string]any + if err := decodeJSON(r, &payload); err != nil { + writeError(w, http.StatusBadRequest, "invalid patch payload") + return + } + + patch, err := prepareAssetPayload(Doc(payload), true) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + if len(patch) == 0 { + writeError(w, http.StatusBadRequest, "no asset changes provided") + return + } + + doc, err := a.patch("assets", id, patch) + 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 + } + + a.recordAuditEvent(r.Context(), claims, "assets.patch", "assets", auditResultSuccess, map[string]any{ + "assetId": id, + }) + writeJSON(w, http.StatusOK, doc) +} + +func (a *app) deleteAsset(w http.ResponseWriter, r *http.Request) { + a.deleteCollection("assets", w, r) +} + +func (a *app) listAssetAccessEvents(w http.ResponseWriter, r *http.Request) { + if a.db == nil { + writeError(w, http.StatusServiceUnavailable, "database not configured") + return + } + id := chi.URLParam(r, "id") + docs, err := a.list("asset_access_events", Doc{"assetId": id}, Sort{{Key: "createdAt", Value: -1}}) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to list access events") + return + } + writeJSON(w, http.StatusOK, docs) +} + +func (a *app) listAssetClaims(w http.ResponseWriter, r *http.Request) { + if a.db == nil { + writeError(w, http.StatusServiceUnavailable, "database not configured") + return + } + id := chi.URLParam(r, "id") + docs, err := a.list("copyright_claims", Doc{"assetId": id}, Sort{{Key: "submittedAt", Value: -1}, {Key: "updatedAt", Value: -1}}) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to list claims") + return + } + writeJSON(w, http.StatusOK, docs) +} + +func (a *app) quarantineAsset(w http.ResponseWriter, r *http.Request) { + a.updateAssetStatusAction(w, r, "quarantined", "assets.quarantine") +} + +func (a *app) disableAsset(w http.ResponseWriter, r *http.Request) { + a.updateAssetStatusAction(w, r, "disabled", "assets.disable") +} + +func (a *app) restoreAsset(w http.ResponseWriter, r *http.Request) { + a.updateAssetStatusAction(w, r, "active", "assets.restore") +} + +func (a *app) encryptAsset(w http.ResponseWriter, r *http.Request) { + claims, _ := r.Context().Value(userClaimsKey).(*accessClaims) + if !a.cfg.AssetEncryptionEnabled { + writeError(w, http.StatusConflict, "asset encryption is not enabled") + return + } + if a.db == nil { + writeError(w, http.StatusServiceUnavailable, "database not configured") + return + } + + id := chi.URLParam(r, "id") + metadata := assetEncryptionMetadata(time.Now().UnixMilli()) + doc, err := a.patch("assets", id, Doc{ + "protectionLevel": "encrypted", + "encryption": metadata, + }) + if err != nil { + if errors.Is(err, ErrNotFound) { + writeError(w, http.StatusNotFound, "resource not found") + return + } + writeError(w, http.StatusInternalServerError, "failed to encrypt asset") + return + } + + a.recordAuditEvent(r.Context(), claims, "assets.encrypt", "assets", auditResultSuccess, map[string]any{ + "assetId": id, + "protectionLevel": "encrypted", + "keyVersion": metadata["keyVersion"], + }) + writeJSON(w, http.StatusOK, doc) +} + +func (a *app) updateAssetStatusAction(w http.ResponseWriter, r *http.Request, targetStatus, action string) { + claims, _ := r.Context().Value(userClaimsKey).(*accessClaims) + if a.db == nil { + writeError(w, http.StatusServiceUnavailable, "database not configured") + return + } + + id := chi.URLParam(r, "id") + doc, err := a.patch("assets", id, Doc{"status": targetStatus}) + if err != nil { + if errors.Is(err, ErrNotFound) { + writeError(w, http.StatusNotFound, "resource not found") + return + } + writeError(w, http.StatusInternalServerError, "failed to update asset status") + return + } + + a.recordAuditEvent(r.Context(), claims, action, "assets", auditResultSuccess, map[string]any{ + "assetId": id, + "status": targetStatus, + }) + writeJSON(w, http.StatusOK, doc) +} + +func assetEncryptionMetadata(now int64) Doc { + return Doc{ + "status": "encrypted", + "algorithm": "AES-256-GCM", + "keyVersion": "v1", + "encryptedAt": now, + } +} + +func prepareAssetPayload(payload Doc, partial bool) (Doc, error) { + if payload == nil { + payload = Doc{} + } + + title, hasTitle, err := getOptionalString(payload, "title") + if err != nil { + return nil, errors.New("title must be a string") + } + if !partial && (!hasTitle || title == "") { + return nil, errors.New("title is required") + } + if hasTitle { + payload["title"] = title + } + + for _, field := range []string{"description", "ownerUserId", "ownerName", "relatedResourceType", "relatedResourceId", "entitlementKey"} { + value, has, err := getOptionalString(payload, field) + if err != nil { + return nil, errors.New(field + " must be a string") + } + if has { + payload[field] = value + } + } + + status, hasStatus, err := getOptionalString(payload, "status") + if err != nil { + return nil, errors.New("status must be a string") + } + if !partial && (!hasStatus || status == "") { + status = "active" + hasStatus = true + } + if hasStatus { + if !isAllowedEnumValue(allowedAssetStatuses, status) { + return nil, errors.New("status must be one of active, archived, quarantined, disabled, or deleted") + } + payload["status"] = status + } + + visibility, hasVisibility, err := getOptionalString(payload, "visibility") + if err != nil { + return nil, errors.New("visibility must be a string") + } + if !partial && (!hasVisibility || visibility == "") { + visibility = "private" + hasVisibility = true + } + if hasVisibility { + if !isAllowedEnumValue(allowedAssetVisibility, visibility) { + return nil, errors.New("visibility must be one of public, authenticated, enrolled, entitled, instructor, admin, or private") + } + payload["visibility"] = visibility + } + + protectionLevel, hasProtectionLevel, err := getOptionalString(payload, "protectionLevel") + if err != nil { + return nil, errors.New("protectionLevel must be a string") + } + if !partial && (!hasProtectionLevel || protectionLevel == "") { + protectionLevel = "standard" + hasProtectionLevel = true + } + if hasProtectionLevel { + if !isAllowedEnumValue(allowedAssetProtectionLevels, protectionLevel) { + return nil, errors.New("protectionLevel must be one of none, standard, encrypted, watermarked, or restricted") + } + payload["protectionLevel"] = protectionLevel + } + + if value, ok := payload["copyright"]; ok { + normalized, err := normalizeAssetCopyright(value) + if err != nil { + return nil, err + } + payload["copyright"] = normalized + } + + return payload, nil +} + +func normalizeAssetCopyright(raw any) (Doc, error) { + mapValue, ok := raw.(map[string]any) + if !ok { + if bsonValue, docOK := raw.(Doc); docOK { + mapValue = map[string]any(bsonValue) + } else { + return nil, errors.New("copyright must be an object") + } + } + + normalized := Doc{} + for _, field := range []string{"ownerName", "ownerUserId", "sourceUrl", "attributionText", "claimStatus"} { + value, has, err := getOptionalString(mapValue, field) + if err != nil { + return nil, errors.New("copyright." + field + " must be a string") + } + if has { + normalized[field] = value + } + } + + licence, hasLicence, err := getOptionalString(mapValue, "licence") + if err != nil { + return nil, errors.New("copyright.licence must be a string") + } + if hasLicence { + normalized["licence"] = licence + } else { + license, hasLicense, err := getOptionalString(mapValue, "license") + if err != nil { + return nil, errors.New("copyright.license must be a string") + } + if hasLicense { + normalized["licence"] = license + } + } + + for _, field := range []string{"requiresAttribution", "allowDownload", "allowReuse", "allowModification", "allowCommercialUse"} { + value, has, err := getOptionalBool(mapValue, field) + if err != nil { + return nil, errors.New("copyright." + field + " must be a boolean") + } + if has { + normalized[field] = value + } + } + + return normalized, nil +} + +func isAllowedEnumValue(allowed map[string]struct{}, value string) bool { + _, ok := allowed[strings.TrimSpace(strings.ToLower(value))] + return ok +} diff --git a/Backend/cmd/api/audit.go b/Backend/cmd/api/audit.go new file mode 100644 index 0000000..ef93037 --- /dev/null +++ b/Backend/cmd/api/audit.go @@ -0,0 +1,276 @@ +package main + +import ( + "context" + "errors" + "net/http" + "strconv" + "strings" + "time" + + "github.com/go-chi/chi/v5" +) + +const ( + auditResultSuccess = "success" + auditResultFailed = "failed" +) + +var sensitiveAuditMetadataTokens = []string{ + "password", + "secret", + "token", + "credential", + "authorization", + "apikey", + "api_key", + "accesskey", + "access_key", + "secretkey", + "secret_key", + "email", + "userid", + "user_id", + "score", + "grade", + "progress", +} + +func (a *app) auditAdminMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + claims, _ := r.Context().Value(userClaimsKey).(*accessClaims) + if claims == nil { + writeError(w, http.StatusUnauthorized, "no authenticated user") + return + } + if !hasRole(claims, "admin") { + writeError(w, http.StatusForbidden, "insufficient role for audit access") + return + } + next.ServeHTTP(w, r) + }) +} + +func (a *app) listAdminAuditEvents(w http.ResponseWriter, r *http.Request) { + if a.db == nil { + writeError(w, http.StatusServiceUnavailable, "database not configured") + return + } + + filter, limit, err := buildAuditQuery(r) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + + ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) + defer cancel() + + cursor, err := a.db.Collection("audit_events").Find( + ctx, + filter, + NewFindOptions().SetSort(Sort{{Key: "timestamp", Value: -1}, {Key: "createdAt", Value: -1}}).SetLimit(int64(limit)), + ) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to list audit events") + return + } + defer cursor.Close(ctx) + + var docs []Doc + if err := cursor.All(ctx, &docs); err != nil { + writeError(w, http.StatusInternalServerError, "failed to list audit events") + return + } + if docs == nil { + docs = []Doc{} + } + + for idx := range docs { + normalizeDocument(docs[idx]) + docs[idx] = redactAuditEventDocument(docs[idx]) + } + + writeJSON(w, http.StatusOK, docs) +} + +func (a *app) getAdminAuditEvent(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + if strings.TrimSpace(id) == "" { + writeError(w, http.StatusBadRequest, "id is required") + return + } + + doc, err := a.getByID("audit_events", id) + if err != nil { + if errors.Is(err, ErrNotFound) { + writeError(w, http.StatusNotFound, "resource not found") + return + } + writeError(w, http.StatusInternalServerError, "failed to load audit event") + return + } + + writeJSON(w, http.StatusOK, redactAuditEventDocument(doc)) +} + +func buildAuditQuery(r *http.Request) (Doc, int, error) { + query := r.URL.Query() + filter := Doc{} + + if action := strings.TrimSpace(query.Get("action")); action != "" { + filter["action"] = action + } + if resource := strings.TrimSpace(query.Get("resource")); resource != "" { + filter["resource"] = resource + } + if result := strings.TrimSpace(query.Get("result")); result != "" { + filter["result"] = result + } + if actor := strings.TrimSpace(query.Get("actor")); actor != "" { + filter["actor"] = actor + } + + timeFilter := Doc{} + if sinceRaw := strings.TrimSpace(query.Get("since")); sinceRaw != "" { + since, err := parseAuditTime(sinceRaw) + if err != nil { + return nil, 0, errors.New("since must be RFC3339 or unix milliseconds") + } + timeFilter["$gte"] = since + } + if untilRaw := strings.TrimSpace(query.Get("until")); untilRaw != "" { + until, err := parseAuditTime(untilRaw) + if err != nil { + return nil, 0, errors.New("until must be RFC3339 or unix milliseconds") + } + timeFilter["$lte"] = until + } + if len(timeFilter) > 0 { + filter["timestamp"] = timeFilter + } + + limit := 50 + if limitRaw := strings.TrimSpace(query.Get("limit")); limitRaw != "" { + parsed, err := strconv.Atoi(limitRaw) + if err != nil || parsed <= 0 { + return nil, 0, errors.New("limit must be a positive integer") + } + if parsed > 200 { + parsed = 200 + } + limit = parsed + } + + return filter, limit, nil +} + +func parseAuditTime(raw string) (time.Time, error) { + if millis, err := strconv.ParseInt(raw, 10, 64); err == nil { + return time.UnixMilli(millis).UTC(), nil + } + parsed, err := time.Parse(time.RFC3339, raw) + if err != nil { + return time.Time{}, err + } + return parsed.UTC(), nil +} + +func (a *app) recordAuditEvent(ctx context.Context, claims *accessClaims, action, resource, result string, metadata map[string]any) { + if a.db == nil { + return + } + action = strings.TrimSpace(action) + resource = strings.TrimSpace(resource) + result = strings.TrimSpace(result) + if action == "" || resource == "" || result == "" { + return + } + + if metadata == nil { + metadata = map[string]any{} + } + + doc := Doc{ + "actor": resolveAuditActor(claims), + "action": action, + "resource": resource, + "result": result, + "timestamp": time.Now().UTC(), + "metadata": Doc(metadata), + } + _, _ = a.create("audit_events", doc) +} + +func resolveAuditActor(claims *accessClaims) string { + if claims == nil { + return "system" + } + if value := strings.TrimSpace(claims.PreferredUsername); value != "" { + return value + } + if value := strings.TrimSpace(claims.Email); value != "" { + return value + } + if value := strings.TrimSpace(claims.Sub); value != "" { + return value + } + return "system" +} + +func redactAuditEventDocument(doc Doc) Doc { + copy := Doc{} + for key, value := range doc { + if key == "metadata" { + copy[key] = redactAuditMetadata("metadata", value) + continue + } + copy[key] = value + } + return copy +} + +func redactAuditMetadata(path string, raw any) any { + switch typed := raw.(type) { + case Doc: + out := Doc{} + for key, value := range typed { + nextPath := path + "." + key + if isSensitiveAuditKey(key) { + out[key] = "[REDACTED]" + continue + } + out[key] = redactAuditMetadata(nextPath, value) + } + return out + case map[string]any: + out := map[string]any{} + for key, value := range typed { + nextPath := path + "." + key + if isSensitiveAuditKey(key) { + out[key] = "[REDACTED]" + continue + } + out[key] = redactAuditMetadata(nextPath, value) + } + return out + case []any: + out := make([]any, len(typed)) + for idx, value := range typed { + out[idx] = redactAuditMetadata(path, value) + } + return out + default: + return raw + } +} + +func isSensitiveAuditKey(key string) bool { + normalized := strings.ToLower(strings.TrimSpace(key)) + for _, token := range sensitiveAuditMetadataTokens { + if strings.Contains(normalized, token) { + return true + } + } + return false +} diff --git a/Backend/cmd/api/authoring.go b/Backend/cmd/api/authoring.go new file mode 100644 index 0000000..3be0b05 --- /dev/null +++ b/Backend/cmd/api/authoring.go @@ -0,0 +1,473 @@ +package main + +import ( + "errors" + "fmt" + "math" + "net/http" + "regexp" + "strconv" + "strings" + + "github.com/go-chi/chi/v5" +) + +var themeHexColorPattern = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`) + +var requiredThemeColors = []string{ + "background", + "foreground", + "primary", + "primaryForeground", + "muted", + "mutedForeground", + "border", + "accent", +} + +type authoringIdentity struct { + UserID string + TenantID string +} + +type themeValidationResult struct { + Valid bool `json:"valid" bson:"valid"` + Errors []string `json:"errors" bson:"errors"` + Warnings []string `json:"warnings" bson:"warnings"` +} + +func (a *app) createAuthoringRun(w http.ResponseWriter, r *http.Request) { + var payload struct { + Brief map[string]any `json:"brief"` + } + if err := decodeJSON(r, &payload); err != nil || len(payload.Brief) == 0 { + writeError(w, http.StatusBadRequest, "brief is required") + return + } + run, err := a.startAuthoringRun(r, Doc(payload.Brief)) + if err != nil { + writeAuthoringError(w, err) + return + } + writeJSON(w, http.StatusCreated, run) +} + +func (a *app) getAuthoringRun(w http.ResponseWriter, r *http.Request) { + run, err := a.getOwnedAuthoringRun(r, chi.URLParam(r, "id")) + if err != nil { + writeAuthoringError(w, err) + return + } + writeJSON(w, http.StatusOK, run) +} + +func (a *app) proposeAuthoringThemeHTTP(w http.ResponseWriter, r *http.Request) { + var payload struct { + IdempotencyKey string `json:"idempotencyKey"` + Theme map[string]any `json:"theme"` + } + if err := decodeJSON(r, &payload); err != nil || strings.TrimSpace(payload.IdempotencyKey) == "" || len(payload.Theme) == 0 { + writeError(w, http.StatusBadRequest, "idempotencyKey and theme are required") + return + } + run, err := a.proposeAuthoringTheme(r, chi.URLParam(r, "id"), strings.TrimSpace(payload.IdempotencyKey), Doc(payload.Theme)) + if err != nil { + writeAuthoringError(w, err) + return + } + writeJSON(w, http.StatusOK, run) +} + +func (a *app) validateAuthoringThemeHTTP(w http.ResponseWriter, r *http.Request) { + result, err := a.validateAuthoringTheme(r, chi.URLParam(r, "id")) + if err != nil { + writeAuthoringError(w, err) + return + } + writeJSON(w, http.StatusOK, result) +} + +func (a *app) deleteAuthoringRun(w http.ResponseWriter, r *http.Request) { + result, err := a.discardAuthoringRun(r, chi.URLParam(r, "id")) + if err != nil { + writeAuthoringError(w, err) + return + } + writeJSON(w, http.StatusOK, result) +} + +func writeAuthoringError(w http.ResponseWriter, err error) { + switch err.Error() { + case "authentication required": + writeError(w, http.StatusUnauthorized, err.Error()) + case "tenant claim required": + writeError(w, http.StatusForbidden, err.Error()) + case "authoring store unavailable": + writeError(w, http.StatusServiceUnavailable, err.Error()) + case "authoring run not found": + writeError(w, http.StatusNotFound, err.Error()) + default: + writeError(w, http.StatusBadRequest, err.Error()) + } +} + +func themeSchemaResource() map[string]any { + properties := map[string]any{} + for _, field := range requiredThemeColors { + properties[field] = map[string]any{"type": "string", "pattern": `^#[0-9a-fA-F]{6}$`} + } + properties["radius"] = map[string]any{"type": "number", "minimum": 0, "maximum": 2} + properties["fontFamily"] = map[string]any{"type": "string", "minLength": 1, "maxLength": 200} + return map[string]any{ + "type": "object", + "properties": properties, + "required": append(requiredThemeColors, "radius", "fontFamily"), + "additionalProperties": false, + "constraints": map[string]any{ + "minimumTextContrast": 4.5, + "arbitraryCSS": false, + }, + } +} + +func (a *app) callExternalAuthoringTool(r *http.Request, name string, arguments map[string]any) (any, error) { + switch name { + case "authoring_start": + brief, ok := asDocument(arguments["brief"]) + if !ok || len(brief) == 0 { + return nil, errors.New("brief is required") + } + return a.startAuthoringRun(r, brief) + case "authoring_get_status": + runID, err := requiredArgumentString(arguments, "runId") + if err != nil { + return nil, err + } + return a.getOwnedAuthoringRun(r, runID) + case "theme_propose": + runID, err := requiredArgumentString(arguments, "runId") + if err != nil { + return nil, err + } + idempotencyKey, err := requiredArgumentString(arguments, "idempotencyKey") + if err != nil { + return nil, err + } + theme, ok := asDocument(arguments["theme"]) + if !ok { + return nil, errors.New("theme is required") + } + return a.proposeAuthoringTheme(r, runID, idempotencyKey, theme) + case "theme_validate": + runID, err := requiredArgumentString(arguments, "runId") + if err != nil { + return nil, err + } + return a.validateAuthoringTheme(r, runID) + case "site_propose_structure": + runID, err := requiredArgumentString(arguments, "runId") + if err != nil { + return nil, err + } + idempotencyKey, err := requiredArgumentString(arguments, "idempotencyKey") + if err != nil { + return nil, err + } + site, ok := asDocument(arguments["site"]) + if !ok { + return nil, errors.New("site is required") + } + return a.proposeAuthoringSiteStructure(r, runID, idempotencyKey, site) + case "site_upsert_page_proposal": + runID, err := requiredArgumentString(arguments, "runId") + if err != nil { + return nil, err + } + idempotencyKey, err := requiredArgumentString(arguments, "idempotencyKey") + if err != nil { + return nil, err + } + page, ok := asDocument(arguments["page"]) + if !ok { + return nil, errors.New("page is required") + } + return a.upsertAuthoringSitePage(r, runID, idempotencyKey, page) + case "site_validate": + runID, err := requiredArgumentString(arguments, "runId") + if err != nil { + return nil, err + } + return a.validateAuthoringSite(r, runID) + case "authoring_get_review_link": + runID, err := requiredArgumentString(arguments, "runId") + if err != nil { + return nil, err + } + return a.authoringReviewLink(r, runID) + case "authoring_discard": + runID, err := requiredArgumentString(arguments, "runId") + if err != nil { + return nil, err + } + return a.discardAuthoringRun(r, runID) + default: + return nil, errors.New("unknown or unavailable tool") + } +} + +func (a *app) startAuthoringRun(r *http.Request, brief Doc) (Doc, error) { + identity, err := a.resolveAuthoringIdentity(r) + if err != nil { + return nil, err + } + if a.db == nil { + return nil, errors.New("authoring store unavailable") + } + return a.create("authoring_runs", Doc{ + "tenantId": identity.TenantID, + "userId": identity.UserID, + "status": "draft", + "brief": brief, + "proposal": Doc{}, + "proposalRevision": 0, + "validation": Doc{}, + "idempotencyKeys": Doc{}, + "source": "mcp", + }) +} + +func (a *app) proposeAuthoringTheme(r *http.Request, runID, idempotencyKey string, theme Doc) (Doc, error) { + run, identity, err := a.getMutableAuthoringRun(r, runID) + if err != nil { + return nil, err + } + + keys, _ := asDocument(run["idempotencyKeys"]) + if keys != nil && anyToString(keys["theme_propose"]) == idempotencyKey { + return run, nil + } + if keys == nil { + keys = Doc{} + } + keys["theme_propose"] = idempotencyKey + + proposal, _ := asDocument(run["proposal"]) + if proposal == nil { + proposal = Doc{} + } + proposal["theme"] = theme + return a.updateAuthoringProposal(runID, identity, run, proposal, keys) +} + +func (a *app) validateAuthoringTheme(r *http.Request, runID string) (Doc, error) { + run, identity, err := a.getOwnedAuthoringRunWithIdentity(r, runID) + if err != nil { + return nil, err + } + proposal, _ := asDocument(run["proposal"]) + theme, ok := asDocument(proposal["theme"]) + if !ok { + return nil, errors.New("theme proposal not found") + } + + validation := validateThemeProposal(theme) + validationDocument, _ := asDocument(run["validation"]) + if validationDocument == nil { + validationDocument = Doc{} + } + validationDocument["theme"] = validation + status := "proposed" + if validation.Valid && allProposalValidationsPass(proposal, validationDocument) { + status = "ready_for_review" + } + updated, err := a.patchWithUnsetScoped("authoring_runs", runID, Doc{ + "validation": validationDocument, + "status": status, + }, nil, identity.TenantID, true) + if err != nil { + return nil, err + } + return Doc{ + "runId": runID, + "status": status, + "proposalRevision": updated["proposalRevision"], + "validation": validation, + "requiresCMSApproval": true, + }, nil +} + +func (a *app) authoringReviewLink(r *http.Request, runID string) (Doc, error) { + run, _, err := a.getOwnedAuthoringRunWithIdentity(r, runID) + if err != nil { + return nil, err + } + if anyToString(run["status"]) != "ready_for_review" { + return nil, errors.New("run must pass validation before review") + } + return Doc{ + "runId": runID, + "status": "ready_for_review", + "reviewUrl": a.cfg.AppBaseURL + "/admin/authoring/runs/" + runID, + }, nil +} + +func (a *app) discardAuthoringRun(r *http.Request, runID string) (Doc, error) { + run, identity, err := a.getOwnedAuthoringRunWithIdentity(r, runID) + if err != nil { + return nil, err + } + if status := anyToString(run["status"]); status == "applied" || status == "published" { + return nil, errors.New("applied runs cannot be discarded") + } + updated, err := a.patchWithUnsetScoped("authoring_runs", runID, Doc{"status": "cancelled"}, nil, identity.TenantID, true) + if err != nil { + return nil, err + } + return Doc{"runId": runID, "status": updated["status"]}, nil +} + +func (a *app) getOwnedAuthoringRun(r *http.Request, runID string) (Doc, error) { + run, _, err := a.getOwnedAuthoringRunWithIdentity(r, runID) + return run, err +} + +func (a *app) getOwnedAuthoringRunWithIdentity(r *http.Request, runID string) (Doc, authoringIdentity, error) { + identity, err := a.resolveAuthoringIdentity(r) + if err != nil { + return nil, authoringIdentity{}, err + } + if a.db == nil { + return nil, authoringIdentity{}, errors.New("authoring store unavailable") + } + run, err := a.getByIDScoped("authoring_runs", runID, identity.TenantID, true) + if err != nil { + if errors.Is(err, ErrNotFound) { + return nil, authoringIdentity{}, errors.New("authoring run not found") + } + return nil, authoringIdentity{}, errors.New("failed to load authoring run") + } + if anyToString(run["userId"]) != identity.UserID && !a.isAuthoringAdmin(r) { + return nil, authoringIdentity{}, errors.New("authoring run not found") + } + return run, identity, nil +} + +func (a *app) resolveAuthoringIdentity(r *http.Request) (authoringIdentity, error) { + if a.cfg.AuthDisabled { + return authoringIdentity{UserID: "development-user", TenantID: "development"}, nil + } + claims, _ := r.Context().Value(userClaimsKey).(*accessClaims) + tenant, _ := r.Context().Value(tenantContextKey).(tenantContext) + userID := resolveClaimsUserID(claims) + if userID == "" { + return authoringIdentity{}, errors.New("authentication required") + } + if strings.TrimSpace(tenant.ID) == "" { + return authoringIdentity{}, errors.New("tenant claim required") + } + return authoringIdentity{UserID: userID, TenantID: strings.TrimSpace(tenant.ID)}, nil +} + +func (a *app) isAuthoringAdmin(r *http.Request) bool { + claims, _ := r.Context().Value(userClaimsKey).(*accessClaims) + return hasAnyRole(claims, "admin", "platform_owner", "platform_admin") +} + +func validateThemeProposal(theme Doc) themeValidationResult { + result := themeValidationResult{Valid: true, Errors: []string{}, Warnings: []string{}} + for _, field := range requiredThemeColors { + value := strings.TrimSpace(anyToString(theme[field])) + if !themeHexColorPattern.MatchString(value) { + result.Errors = append(result.Errors, field+" must be a six-digit hex color") + } + } + fontFamily := strings.TrimSpace(anyToString(theme["fontFamily"])) + if fontFamily == "" || len(fontFamily) > 200 { + result.Errors = append(result.Errors, "fontFamily is required and must be at most 200 characters") + } + radius, ok := numericValue(theme["radius"]) + if !ok || radius < 0 || radius > 2 { + result.Errors = append(result.Errors, "radius must be between 0 and 2") + } + if len(result.Errors) == 0 { + for _, pair := range [][3]string{ + {"foreground", "background", "foreground/background"}, + {"primaryForeground", "primary", "primary foreground/primary"}, + {"mutedForeground", "muted", "muted foreground/muted"}, + } { + ratio := contrastRatio(anyToString(theme[pair[0]]), anyToString(theme[pair[1]])) + if ratio < 4.5 { + result.Errors = append(result.Errors, fmt.Sprintf("%s contrast %.2f is below 4.5", pair[2], ratio)) + } + } + } + result.Valid = len(result.Errors) == 0 + return result +} + +func contrastRatio(first, second string) float64 { + firstLuminance := relativeLuminance(first) + secondLuminance := relativeLuminance(second) + lighter := math.Max(firstLuminance, secondLuminance) + darker := math.Min(firstLuminance, secondLuminance) + return (lighter + 0.05) / (darker + 0.05) +} + +func relativeLuminance(color string) float64 { + if !themeHexColorPattern.MatchString(color) { + return 0 + } + channels := make([]float64, 0, 3) + for index := 1; index < 7; index += 2 { + value, _ := strconv.ParseInt(color[index:index+2], 16, 64) + channel := float64(value) / 255 + if channel <= 0.04045 { + channel /= 12.92 + } else { + channel = math.Pow((channel+0.055)/1.055, 2.4) + } + channels = append(channels, channel) + } + return 0.2126*channels[0] + 0.7152*channels[1] + 0.0722*channels[2] +} + +func requiredArgumentString(arguments map[string]any, key string) (string, error) { + value := strings.TrimSpace(anyToString(arguments[key])) + if value == "" { + return "", fmt.Errorf("%s is required", key) + } + return value, nil +} + +func asDocument(value any) (Doc, bool) { + switch typed := value.(type) { + case Doc: + return typed, true + case map[string]any: + return Doc(typed), true + default: + return nil, false + } +} + +func int64Number(value any) int64 { + number, _ := numericValue(value) + return int64(number) +} + +func numericValue(value any) (float64, bool) { + switch typed := value.(type) { + case int: + return float64(typed), true + case int32: + return float64(typed), true + case int64: + return float64(typed), true + case float32: + return float64(typed), true + case float64: + return typed, true + default: + return 0, false + } +} \ No newline at end of file diff --git a/Backend/cmd/api/authoring_site.go b/Backend/cmd/api/authoring_site.go new file mode 100644 index 0000000..6b6a52c --- /dev/null +++ b/Backend/cmd/api/authoring_site.go @@ -0,0 +1,352 @@ +package main + +import ( + "errors" + "fmt" + "net/http" + "strings" + + "github.com/go-chi/chi/v5" +) + +type siteValidationResult struct { + Valid bool `json:"valid" bson:"valid"` + Errors []string `json:"errors" bson:"errors"` + Warnings []string `json:"warnings" bson:"warnings"` +} + +func (a *app) proposeAuthoringSiteStructureHTTP(w http.ResponseWriter, r *http.Request) { + var payload struct { + IdempotencyKey string `json:"idempotencyKey"` + Site map[string]any `json:"site"` + } + if err := decodeJSON(r, &payload); err != nil || strings.TrimSpace(payload.IdempotencyKey) == "" || len(payload.Site) == 0 { + writeError(w, http.StatusBadRequest, "idempotencyKey and site are required") + return + } + run, err := a.proposeAuthoringSiteStructure(r, chi.URLParam(r, "id"), strings.TrimSpace(payload.IdempotencyKey), Doc(payload.Site)) + if err != nil { + writeAuthoringError(w, err) + return + } + writeJSON(w, http.StatusOK, run) +} + +func (a *app) upsertAuthoringSitePageHTTP(w http.ResponseWriter, r *http.Request) { + var payload struct { + IdempotencyKey string `json:"idempotencyKey"` + Page map[string]any `json:"page"` + } + if err := decodeJSON(r, &payload); err != nil || strings.TrimSpace(payload.IdempotencyKey) == "" || len(payload.Page) == 0 { + writeError(w, http.StatusBadRequest, "idempotencyKey and page are required") + return + } + run, err := a.upsertAuthoringSitePage(r, chi.URLParam(r, "id"), strings.TrimSpace(payload.IdempotencyKey), Doc(payload.Page)) + if err != nil { + writeAuthoringError(w, err) + return + } + writeJSON(w, http.StatusOK, run) +} + +func (a *app) validateAuthoringSiteHTTP(w http.ResponseWriter, r *http.Request) { + result, err := a.validateAuthoringSite(r, chi.URLParam(r, "id")) + if err != nil { + writeAuthoringError(w, err) + return + } + writeJSON(w, http.StatusOK, result) +} + +func siteSchemaResource() map[string]any { + return map[string]any{ + "structure": map[string]any{ + "type": "object", "required": []string{"title", "navigation"}, + "properties": map[string]any{ + "title": map[string]any{"type": "string", "minLength": 1, "maxLength": 200}, + "description": map[string]any{"type": "string", "maxLength": 1000}, + "navigation": map[string]any{"type": "array", "items": map[string]any{ + "type": "object", "required": []string{"label", "pageId"}, + "properties": map[string]any{"label": map[string]any{"type": "string"}, "pageId": map[string]any{"type": "string"}}, + "additionalProperties": false, + }}, + }, + "additionalProperties": false, + }, + "page": map[string]any{ + "type": "object", "required": []string{"id", "name", "slug", "blocks"}, + "properties": map[string]any{ + "id": map[string]any{"type": "string", "minLength": 1, "maxLength": 100}, + "name": map[string]any{"type": "string", "minLength": 1, "maxLength": 200}, + "slug": map[string]any{"type": "string", "pattern": `^[a-z0-9]+(?:-[a-z0-9]+)*$`}, + "seo": map[string]any{"type": "object"}, + "blocks": map[string]any{"type": "array"}, + "customBlocks": map[string]any{"type": "array"}, + }, + "additionalProperties": false, + }, + "constraints": map[string]any{"proposalOnly": true, "uniquePageAndBlockIds": true, "navigationTargetsMustExist": true}, + } +} + +func (a *app) getMutableAuthoringRun(r *http.Request, runID string) (Doc, authoringIdentity, error) { + run, identity, err := a.getOwnedAuthoringRunWithIdentity(r, runID) + if err != nil { + return nil, authoringIdentity{}, err + } + if status := anyToString(run["status"]); status == "cancelled" || status == "applied" || status == "published" { + return nil, authoringIdentity{}, fmt.Errorf("run cannot be changed while status is %s", status) + } + return run, identity, nil +} + +func (a *app) updateAuthoringProposal(runID string, identity authoringIdentity, run, proposal, keys Doc) (Doc, error) { + return a.patchWithUnsetScoped("authoring_runs", runID, Doc{ + "proposal": proposal, "proposalRevision": int64Number(run["proposalRevision"]) + 1, + "status": "proposed", "validation": Doc{}, "idempotencyKeys": keys, + }, nil, identity.TenantID, true) +} + +func allProposalValidationsPass(proposal, validation Doc) bool { + for _, area := range []string{"theme", "site"} { + if _, proposed := proposal[area]; !proposed { + continue + } + result, ok := asDocument(validation[area]) + if !ok || result["valid"] != true { + return false + } + } + return true +} + +func (a *app) proposeAuthoringSiteStructure(r *http.Request, runID, idempotencyKey string, structure Doc) (Doc, error) { + run, identity, err := a.getMutableAuthoringRun(r, runID) + if err != nil { + return nil, err + } + if validationErrors := validateSiteStructure(structure); len(validationErrors) > 0 { + return nil, fmt.Errorf("invalid site structure: %s", strings.Join(validationErrors, "; ")) + } + keys, _ := asDocument(run["idempotencyKeys"]) + if keys != nil && anyToString(keys["site_propose_structure"]) == idempotencyKey { + return run, nil + } + if keys == nil { + keys = Doc{} + } + keys["site_propose_structure"] = idempotencyKey + proposal, _ := asDocument(run["proposal"]) + if proposal == nil { + proposal = Doc{} + } + site, _ := asDocument(proposal["site"]) + if site == nil { + site = Doc{"pages": Doc{}} + } + site["structure"] = structure + proposal["site"] = site + return a.updateAuthoringProposal(runID, identity, run, proposal, keys) +} + +func (a *app) upsertAuthoringSitePage(r *http.Request, runID, idempotencyKey string, page Doc) (Doc, error) { + run, identity, err := a.getMutableAuthoringRun(r, runID) + if err != nil { + return nil, err + } + pageID := strings.TrimSpace(anyToString(page["id"])) + if validationErrors := validateSitePage(page); len(validationErrors) > 0 { + return nil, fmt.Errorf("invalid site page: %s", strings.Join(validationErrors, "; ")) + } + keys, _ := asDocument(run["idempotencyKeys"]) + keyName := "site_upsert_page_proposal:" + pageID + if keys != nil && anyToString(keys[keyName]) == idempotencyKey { + return run, nil + } + if keys == nil { + keys = Doc{} + } + keys[keyName] = idempotencyKey + proposal, _ := asDocument(run["proposal"]) + if proposal == nil { + proposal = Doc{} + } + site, _ := asDocument(proposal["site"]) + if site == nil { + site = Doc{} + } + pages, _ := asDocument(site["pages"]) + if pages == nil { + pages = Doc{} + } + pages[pageID] = page + site["pages"] = pages + proposal["site"] = site + return a.updateAuthoringProposal(runID, identity, run, proposal, keys) +} + +func (a *app) validateAuthoringSite(r *http.Request, runID string) (Doc, error) { + run, identity, err := a.getOwnedAuthoringRunWithIdentity(r, runID) + if err != nil { + return nil, err + } + proposal, _ := asDocument(run["proposal"]) + site, ok := asDocument(proposal["site"]) + if !ok { + return nil, errors.New("site proposal not found") + } + validation := validateSiteProposal(site) + validationDocument, _ := asDocument(run["validation"]) + if validationDocument == nil { + validationDocument = Doc{} + } + validationDocument["site"] = validation + status := "proposed" + if validation.Valid && allProposalValidationsPass(proposal, validationDocument) { + status = "ready_for_review" + } + updated, err := a.patchWithUnsetScoped("authoring_runs", runID, Doc{"validation": validationDocument, "status": status}, nil, identity.TenantID, true) + if err != nil { + return nil, err + } + return Doc{"runId": runID, "status": status, "proposalRevision": updated["proposalRevision"], "validation": validation, "requiresCMSApproval": true}, nil +} + +func validateSiteProposal(site Doc) siteValidationResult { + result := siteValidationResult{Valid: true, Errors: []string{}, Warnings: []string{}} + structure, ok := asDocument(site["structure"]) + if !ok { + result.Errors = append(result.Errors, "site structure is required") + } else { + result.Errors = append(result.Errors, validateSiteStructure(structure)...) + } + pages, ok := asDocument(site["pages"]) + if !ok || len(pages) == 0 { + result.Errors = append(result.Errors, "at least one site page is required") + pages = Doc{} + } + slugs := map[string]string{} + for pageID, rawPage := range pages { + page, pageOK := asDocument(rawPage) + if !pageOK { + result.Errors = append(result.Errors, fmt.Sprintf("page %s must be an object", pageID)) + continue + } + result.Errors = append(result.Errors, validateSitePage(page)...) + if anyToString(page["id"]) != pageID { + result.Errors = append(result.Errors, fmt.Sprintf("page %s id must match its proposal key", pageID)) + } + slug := strings.TrimSpace(anyToString(page["slug"])) + if previousID, exists := slugs[slug]; slug != "" && exists { + result.Errors = append(result.Errors, fmt.Sprintf("pages %s and %s use duplicate slug %s", previousID, pageID, slug)) + } else if slug != "" { + slugs[slug] = pageID + } + } + if structure != nil { + navigation, _ := toDocumentArray(structure["navigation"]) + for index, item := range navigation { + pageID := strings.TrimSpace(anyToString(item["pageId"])) + if _, exists := pages[pageID]; pageID != "" && !exists { + result.Errors = append(result.Errors, fmt.Sprintf("navigation item %d targets unknown page %s", index+1, pageID)) + } + } + } + result.Valid = len(result.Errors) == 0 + return result +} + +func validateSiteStructure(structure Doc) []string { + validationErrors := []string{} + title := strings.TrimSpace(anyToString(structure["title"])) + if title == "" || len(title) > 200 { + validationErrors = append(validationErrors, "site title is required and must be at most 200 characters") + } + if description := anyToString(structure["description"]); len(description) > 1000 { + validationErrors = append(validationErrors, "site description must be at most 1000 characters") + } + navigation, ok := toDocumentArray(structure["navigation"]) + if !ok { + return append(validationErrors, "navigation must be an array") + } + for index, item := range navigation { + if strings.TrimSpace(anyToString(item["label"])) == "" { + validationErrors = append(validationErrors, fmt.Sprintf("navigation item %d label is required", index+1)) + } + if strings.TrimSpace(anyToString(item["pageId"])) == "" { + validationErrors = append(validationErrors, fmt.Sprintf("navigation item %d pageId is required", index+1)) + } + } + return validationErrors +} + +func validateSitePage(page Doc) []string { + validationErrors := []string{} + pageID := strings.TrimSpace(anyToString(page["id"])) + name := strings.TrimSpace(anyToString(page["name"])) + slug := strings.TrimSpace(anyToString(page["slug"])) + if pageID == "" || len(pageID) > 100 { + validationErrors = append(validationErrors, "page id is required and must be at most 100 characters") + } + if name == "" || len(name) > 200 { + validationErrors = append(validationErrors, fmt.Sprintf("page %s name is required and must be at most 200 characters", pageID)) + } + if !slugPattern.MatchString(slug) { + validationErrors = append(validationErrors, fmt.Sprintf("page %s slug must contain lowercase letters, numbers, and hyphens only", pageID)) + } + if _, _, err := normalizeSEOMetadata(page["seo"]); err != nil { + validationErrors = append(validationErrors, fmt.Sprintf("page %s %s", pageID, err.Error())) + } + blocks, ok := toDocumentArray(page["blocks"]) + if !ok { + return append(validationErrors, fmt.Sprintf("page %s blocks must be an array", pageID)) + } + return append(validationErrors, validateSiteBlocks(pageID, blocks, map[string]struct{}{})...) +} + +func validateSiteBlocks(pageID string, blocks []Doc, seen map[string]struct{}) []string { + validationErrors := []string{} + for _, block := range blocks { + blockID := strings.TrimSpace(anyToString(block["id"])) + if blockID == "" { + validationErrors = append(validationErrors, fmt.Sprintf("page %s contains a block without an id", pageID)) + } else if _, exists := seen[blockID]; exists { + validationErrors = append(validationErrors, fmt.Sprintf("page %s contains duplicate block id %s", pageID, blockID)) + } else { + seen[blockID] = struct{}{} + } + if strings.TrimSpace(anyToString(block["type"])) == "" { + validationErrors = append(validationErrors, fmt.Sprintf("page %s block %s type is required", pageID, blockID)) + } + if rawChildren, exists := block["children"]; exists { + children, ok := toDocumentArray(rawChildren) + if !ok { + validationErrors = append(validationErrors, fmt.Sprintf("page %s block %s children must be an array", pageID, blockID)) + } else { + validationErrors = append(validationErrors, validateSiteBlocks(pageID, children, seen)...) + } + } + } + return validationErrors +} + +func toDocumentArray(value any) ([]Doc, bool) { + var values []any + switch typed := value.(type) { + case List: + values = []any(typed) + case []any: + values = typed + default: + return nil, false + } + result := make([]Doc, 0, len(values)) + for _, value := range values { + document, ok := asDocument(value) + if !ok { + return nil, false + } + result = append(result, document) + } + return result, true +} \ No newline at end of file diff --git a/Backend/cmd/api/billing.go b/Backend/cmd/api/billing.go new file mode 100644 index 0000000..9dae226 --- /dev/null +++ b/Backend/cmd/api/billing.go @@ -0,0 +1,1363 @@ +package main + +import ( + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/json" + "errors" + "fmt" + "io" + "log" + "net/http" + "net/url" + "sort" + "strconv" + "strings" + "time" +) + +var errInvalidStripeSignature = errors.New("invalid stripe signature") + +type stripeCheckoutRequest struct { + PriceID string `json:"priceId"` + LineItems []stripeCheckoutLineItem `json:"lineItems"` + Mode string `json:"mode"` + Scope string `json:"scope"` + Tenant string `json:"tenantId"` +} + +type stripeCheckoutResponse struct { + SessionID string `json:"sessionId"` + CheckoutURL string `json:"checkoutUrl"` +} + +type stripeCheckoutParams struct { + CustomerID string + CustomerEmail string + PriceID string + LineItems []stripeCheckoutLineItem + Mode string + SuccessURL string + CancelURL string + Metadata map[string]string +} + +type stripeCheckoutLineItem struct { + PriceID string `json:"priceId"` + Quantity int `json:"quantity"` +} + +type stripeCheckoutSession struct { + ID string + URL string +} + +type billingPricePolicy struct { + PriceID string + Mode string + EntitlementDurationDays int + TrialDays int + GraceDays int +} + +type stripeCustomerParams struct { + Email string + Metadata map[string]string +} + +type stripeCustomer struct { + ID string + Email string +} + +type stripeWebhookEvent struct { + ID string `json:"id"` + Type string `json:"type"` + Created int64 `json:"created"` + Livemode bool `json:"livemode"` + Data stripeEventData `json:"data"` +} + +type stripeEventData struct { + Object map[string]any `json:"object"` +} + +type billingWebhookProcessResult struct { + HTTPStatus int + Code string + Message string +} + +type stripeEventMetadata struct { + UserID string + PriceID string + PriceIDs []string + Scope string + TenantID string + PlatformAccountID string + BillingCustomerID string + InitiatedByUserID string + InitiatedByTenantID string + IsPlatformActor bool +} + +type entitlementLifecyclePatch struct { + UserID string + Key string + Status string + Reason string + SourceEventID string + Scope string + TenantID string + PlatformAccountID string +} + +type billingWebhookProcessorDeps struct { + LookupEvent func(context.Context, string) (Doc, error) + CreateEvent func(context.Context, Doc) (Doc, error) + PatchEvent func(context.Context, string, Doc) (Doc, error) + ResolveEntitlement func(context.Context, string) (string, error) + EnsureEntitlement func(context.Context, string, string, stripeWebhookEvent) error + UpdateEntitlement func(context.Context, entitlementLifecyclePatch) error + ValidateOwnership func(context.Context, stripeEventMetadata) error + Now func() time.Time + Logf func(string, ...any) +} + +type stripeService interface { + EnsureCustomer(context.Context, stripeCustomerParams) (stripeCustomer, error) + CreateCheckoutSession(context.Context, stripeCheckoutParams) (stripeCheckoutSession, error) +} + +func (a *app) activeStripeService() stripeService { + if a.stripeService != nil { + return a.stripeService + } + return newStripeService(a.cfg) +} + +func (a *app) activeStripeWebhookVerifier() func([]byte, string) (stripeWebhookEvent, error) { + if a.stripeWebhookVerifier != nil { + return a.stripeWebhookVerifier + } + return func(payload []byte, signatureHeader string) (stripeWebhookEvent, error) { + return verifyStripeWebhookEvent(payload, signatureHeader, a.cfg.StripeWebhookSecret, a.cfg.StripeWebhookTolerance, time.Now()) + } +} + +func (a *app) handleStripeWebhook(w http.ResponseWriter, r *http.Request) { + payload, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) + if err != nil { + writeError(w, http.StatusBadRequest, "failed to read webhook payload") + return + } + + event, err := a.activeStripeWebhookVerifier()(payload, r.Header.Get("Stripe-Signature")) + if err != nil { + if errors.Is(err, errInvalidStripeSignature) { + writeError(w, http.StatusUnauthorized, "invalid stripe signature") + return + } + writeError(w, http.StatusServiceUnavailable, err.Error()) + return + } + + result, err := processStripeWebhookEvent(r.Context(), event, a.billingWebhookProcessorDeps()) + if err != nil { + writeError(w, result.HTTPStatus, result.Message) + return + } + + writeJSON(w, result.HTTPStatus, billingWebhookResponse{ + Status: result.Code, + EventID: event.ID, + Message: result.Message, + }) +} + +func (a *app) billingWebhookProcessorDeps() billingWebhookProcessorDeps { + return billingWebhookProcessorDeps{ + LookupEvent: a.lookupBillingEvent, + CreateEvent: a.createBillingEvent, + PatchEvent: a.patchBillingEvent, + ResolveEntitlement: a.resolveBillingEntitlementKey, + EnsureEntitlement: a.ensureStripeEntitlement, + UpdateEntitlement: a.updateEntitlementLifecycleFromBilling, + ValidateOwnership: a.validateBillingEventOwnership, + Now: func() time.Time { return time.Now().UTC() }, + Logf: log.Printf, + } +} + +func processStripeWebhookEvent(ctx context.Context, event stripeWebhookEvent, deps billingWebhookProcessorDeps) (billingWebhookProcessResult, error) { + nowFn := deps.Now + if nowFn == nil { + nowFn = func() time.Time { return time.Now().UTC() } + } + logf := deps.Logf + if logf == nil { + logf = log.Printf + } + + if strings.TrimSpace(event.ID) == "" { + return billingWebhookProcessResult{HTTPStatus: http.StatusBadRequest, Code: "invalid", Message: "stripe event id is required"}, errors.New("stripe event id is required") + } + if strings.TrimSpace(event.Type) == "" { + return billingWebhookProcessResult{HTTPStatus: http.StatusBadRequest, Code: "invalid", Message: "stripe event type is required"}, errors.New("stripe event type is required") + } + + if existing, err := deps.LookupEvent(ctx, event.ID); err == nil && existing != nil { + return billingWebhookProcessResult{HTTPStatus: http.StatusOK, Code: "duplicate", Message: "stripe event already processed"}, nil + } else if err != nil && !errors.Is(err, ErrNotFound) { + return billingWebhookProcessResult{HTTPStatus: http.StatusInternalServerError, Code: "failed", Message: "failed to load billing event"}, err + } + + eventMetadata := stripeWebhookMetadata(event) + receivedAt := nowFn().UnixMilli() + if _, err := deps.CreateEvent(ctx, Doc{ + "id": strings.TrimSpace(event.ID), + "provider": "stripe", + "type": strings.TrimSpace(event.Type), + "status": "received", + "receivedAt": receivedAt, + "stripeCreatedAt": event.Created * 1000, + "livemode": event.Livemode, + "data": event.Data.Object, + "scope": eventMetadata.Scope, + "tenantId": eventMetadata.TenantID, + "platformAccountId": eventMetadata.PlatformAccountID, + "userId": eventMetadata.UserID, + "priceId": eventMetadata.PriceID, + "initiatedByUserId": eventMetadata.InitiatedByUserID, + }); err != nil { + return billingWebhookProcessResult{HTTPStatus: http.StatusInternalServerError, Code: "failed", Message: "failed to persist billing event"}, err + } + + finalize := func(status string, patch Doc, message string, err error) (billingWebhookProcessResult, error) { + if patch == nil { + patch = Doc{} + } + patch["status"] = status + patch["processedAt"] = nowFn().UnixMilli() + if _, patchErr := deps.PatchEvent(ctx, event.ID, patch); patchErr != nil && err == nil { + err = patchErr + status = "failed" + message = "failed to update billing event" + } + if err != nil { + logf("billing webhook event %s failed: %v", event.ID, err) + return billingWebhookProcessResult{HTTPStatus: http.StatusInternalServerError, Code: status, Message: message}, err + } + if status == "ignored" { + logf("billing webhook event %s ignored: %s", event.ID, message) + } + return billingWebhookProcessResult{HTTPStatus: http.StatusOK, Code: status, Message: message}, nil + } + + if !isSupportedStripeWebhookType(event.Type) { + return finalize("ignored", Doc{"reason": "unsupported_event_type"}, "unsupported stripe event type", nil) + } + + metadata := stripeWebhookMetadata(event) + if metadata.UserID == "" || len(metadata.PriceIDs) == 0 { + return finalize("ignored", Doc{"reason": "missing_metadata"}, "stripe event missing userId or priceId metadata", nil) + } + if deps.ValidateOwnership != nil { + if err := deps.ValidateOwnership(ctx, metadata); err != nil { + return finalize("failed", Doc{"reason": "ownership_validation_failed", "userId": metadata.UserID, "priceId": metadata.PriceID}, "billing event ownership validation failed", err) + } + } + lifecycleAction := stripeWebhookLifecycleAction(event.Type) + processedPriceIDs := make([]string, 0, len(metadata.PriceIDs)) + processedEntitlementKeys := make([]string, 0, len(metadata.PriceIDs)) + unmappedPriceIDs := make([]string, 0) + for _, priceID := range metadata.PriceIDs { + entitlementKey, err := deps.ResolveEntitlement(ctx, priceID) + if err != nil { + return finalize("failed", Doc{"reason": "entitlement_lookup_failed", "priceId": priceID, "userId": metadata.UserID}, "failed to resolve entitlement for stripe price", err) + } + if entitlementKey == "" { + unmappedPriceIDs = append(unmappedPriceIDs, priceID) + continue + } + switch lifecycleAction { + case "grant": + if err := deps.EnsureEntitlement(ctx, metadata.UserID, entitlementKey, event); err != nil { + return finalize("failed", Doc{"reason": "entitlement_grant_failed", "priceId": priceID, "userId": metadata.UserID, "entitlementKey": entitlementKey}, "failed to grant entitlement", err) + } + case "grace": + if deps.UpdateEntitlement != nil { + err := deps.UpdateEntitlement(ctx, entitlementLifecyclePatch{ + UserID: metadata.UserID, + Key: entitlementKey, + Status: entitlementStatusGrace, + Reason: "payment_failed", + SourceEventID: event.ID, + Scope: metadata.Scope, + TenantID: metadata.TenantID, + PlatformAccountID: metadata.PlatformAccountID, + }) + if err != nil { + return finalize("failed", Doc{"reason": "entitlement_grace_failed", "priceId": priceID, "userId": metadata.UserID, "entitlementKey": entitlementKey}, "failed to apply entitlement grace state", err) + } + } + case "revoke": + if deps.UpdateEntitlement != nil { + err := deps.UpdateEntitlement(ctx, entitlementLifecyclePatch{ + UserID: metadata.UserID, + Key: entitlementKey, + Status: entitlementStatusRevoked, + Reason: "billing_revocation", + SourceEventID: event.ID, + Scope: metadata.Scope, + TenantID: metadata.TenantID, + PlatformAccountID: metadata.PlatformAccountID, + }) + if err != nil { + return finalize("failed", Doc{"reason": "entitlement_revoke_failed", "priceId": priceID, "userId": metadata.UserID, "entitlementKey": entitlementKey}, "failed to revoke entitlement", err) + } + } + } + processedPriceIDs = append(processedPriceIDs, priceID) + processedEntitlementKeys = append(processedEntitlementKeys, entitlementKey) + } + if len(processedPriceIDs) == 0 { + return finalize("ignored", Doc{"reason": "missing_entitlement_mapping", "priceIds": metadata.PriceIDs, "userId": metadata.UserID}, "no entitlement mapping found for stripe price", nil) + } + return finalize("processed", Doc{ + "userId": metadata.UserID, + "priceId": processedPriceIDs[0], + "priceIds": processedPriceIDs, + "entitlementKey": processedEntitlementKeys[0], + "entitlementKeys": processedEntitlementKeys, + "unmappedPriceIds": unmappedPriceIDs, + "scope": metadata.Scope, + "tenantId": metadata.TenantID, + "platformAccountId": metadata.PlatformAccountID, + }, "stripe event processed", nil) +} + +func isSupportedStripeWebhookType(eventType string) bool { + switch strings.TrimSpace(eventType) { + case "checkout.session.completed", + "checkout.session.async_payment_succeeded", + "payment_intent.succeeded", + "invoice.payment_succeeded", + "invoice.payment_failed", + "payment_intent.payment_failed", + "customer.subscription.deleted", + "charge.refunded": + return true + default: + return false + } +} + +func stripeWebhookLifecycleAction(eventType string) string { + switch strings.TrimSpace(eventType) { + case "invoice.payment_failed", "payment_intent.payment_failed": + return "grace" + case "customer.subscription.deleted", "charge.refunded": + return "revoke" + default: + return "grant" + } +} + +func stripeWebhookMetadata(event stripeWebhookEvent) stripeEventMetadata { + metadata, _ := event.Data.Object["metadata"].(map[string]any) + userID, _ := asString(metadata["userId"]) + priceID, _ := asString(metadata["priceId"]) + rawPriceIDs, _ := asString(metadata["priceIds"]) + scope, _ := asString(metadata["scope"]) + tenantID, _ := asString(metadata["tenantId"]) + platformAccountID, _ := asString(metadata["platformAccountId"]) + billingCustomerID, _ := asString(metadata["billingCustomerId"]) + initiatedByUserID, _ := asString(metadata["initiatedByUserId"]) + initiatedByTenantID, _ := asString(metadata["initiatedByTenantId"]) + isPlatformActorRaw, _ := asString(metadata["isPlatformActor"]) + priceIDs := normalizeStripePriceIDs(strings.TrimSpace(priceID), strings.Split(rawPriceIDs, ","), stripeWebhookLineItemPriceIDs(event.Data.Object)) + return stripeEventMetadata{ + UserID: strings.TrimSpace(userID), + PriceID: firstStripePriceID(priceIDs), + PriceIDs: priceIDs, + Scope: strings.TrimSpace(strings.ToLower(scope)), + TenantID: strings.TrimSpace(tenantID), + PlatformAccountID: strings.TrimSpace(platformAccountID), + BillingCustomerID: strings.TrimSpace(billingCustomerID), + InitiatedByUserID: strings.TrimSpace(initiatedByUserID), + InitiatedByTenantID: strings.TrimSpace(initiatedByTenantID), + IsPlatformActor: strings.EqualFold(strings.TrimSpace(isPlatformActorRaw), "true"), + } +} + +func verifyStripeWebhookEvent(payload []byte, signatureHeader, secret string, tolerance time.Duration, now time.Time) (stripeWebhookEvent, error) { + if strings.TrimSpace(secret) == "" { + return stripeWebhookEvent{}, errors.New("stripe webhook secret not configured") + } + timestamp, signatures, err := parseStripeSignatureHeader(signatureHeader) + if err != nil { + return stripeWebhookEvent{}, errInvalidStripeSignature + } + if tolerance > 0 && now.Sub(time.Unix(timestamp, 0)) > tolerance { + return stripeWebhookEvent{}, errInvalidStripeSignature + } + mac := hmac.New(sha256.New, []byte(secret)) + _, _ = mac.Write([]byte(strconv.FormatInt(timestamp, 10))) + _, _ = mac.Write([]byte(".")) + _, _ = mac.Write(payload) + expected := fmt.Sprintf("%x", mac.Sum(nil)) + valid := false + for _, signature := range signatures { + if hmac.Equal([]byte(signature), []byte(expected)) { + valid = true + break + } + } + if !valid { + return stripeWebhookEvent{}, errInvalidStripeSignature + } + + var event stripeWebhookEvent + if err := json.Unmarshal(payload, &event); err != nil { + return stripeWebhookEvent{}, errInvalidStripeSignature + } + return event, nil +} + +func parseStripeSignatureHeader(header string) (int64, []string, error) { + parts := strings.Split(header, ",") + var timestamp int64 + hasTimestamp := false + signatures := []string{} + for _, part := range parts { + segment := strings.TrimSpace(part) + key, value, ok := strings.Cut(segment, "=") + if !ok { + continue + } + switch strings.TrimSpace(key) { + case "t": + parsed, err := strconv.ParseInt(strings.TrimSpace(value), 10, 64) + if err != nil { + return 0, nil, errInvalidStripeSignature + } + timestamp = parsed + hasTimestamp = true + case "v1": + if signature := strings.TrimSpace(value); signature != "" { + signatures = append(signatures, signature) + } + } + } + if !hasTimestamp || len(signatures) == 0 { + return 0, nil, errInvalidStripeSignature + } + sort.Strings(signatures) + return timestamp, signatures, nil +} + +func (a *app) lookupBillingEvent(ctx context.Context, id string) (Doc, error) { + if a.db == nil { + return nil, errors.New("database not configured") + } + _ = ctx + return a.getByID("billing_events", strings.TrimSpace(id)) +} + +func (a *app) createBillingEvent(ctx context.Context, doc Doc) (Doc, error) { + if a.db == nil { + return nil, errors.New("database not configured") + } + _ = ctx + return a.create("billing_events", doc) +} + +func (a *app) patchBillingEvent(ctx context.Context, id string, patch Doc) (Doc, error) { + if a.db == nil { + return nil, errors.New("database not configured") + } + _ = ctx + return a.patch("billing_events", strings.TrimSpace(id), patch) +} + +func (a *app) resolveBillingEntitlementKey(ctx context.Context, priceID string) (string, error) { + if a.db == nil { + return "", errors.New("database not configured") + } + priceID = strings.TrimSpace(priceID) + if priceID == "" { + return "", nil + } + cohorts, err := a.list("live_course_cohorts", Doc{"priceId": priceID}, Sort{{Key: "updatedAt", Value: -1}, {Key: "createdAt", Value: -1}}) + if err != nil { + return "", err + } + if len(cohorts) > 0 { + return resolveEntitlementKey(nil, cohorts[0]), nil + } + courses, err := a.list("live_courses", Doc{"priceId": priceID}, Sort{{Key: "updatedAt", Value: -1}, {Key: "createdAt", Value: -1}}) + if err != nil { + return "", err + } + if len(courses) > 0 { + return resolveEntitlementKey(courses[0], nil), nil + } + return "", nil +} + +func (a *app) resolveBillingPricePolicy(ctx context.Context, priceID, mode string) (billingPricePolicy, error) { + _ = ctx + policy := billingPricePolicy{ + PriceID: strings.TrimSpace(priceID), + Mode: normalizeStripeCheckoutMode(mode), + EntitlementDurationDays: 3650, + TrialDays: a.cfg.DefaultTrialDays, + GraceDays: 7, + } + if policy.Mode == "subscription" { + policy.EntitlementDurationDays = 32 + } + if a.db == nil || policy.PriceID == "" { + return policy, nil + } + docs, err := a.list("billing_price_policies", Doc{"priceId": policy.PriceID}, Sort{{Key: "updatedAt", Value: -1}, {Key: "createdAt", Value: -1}}) + if err != nil { + return policy, err + } + if len(docs) == 0 { + return policy, nil + } + doc := docs[0] + if overrideMode := strings.TrimSpace(anyToString(doc["mode"])); overrideMode != "" { + policy.Mode = normalizeStripeCheckoutMode(overrideMode) + } + if duration := intNumber(doc["durationDays"]); duration > 0 { + policy.EntitlementDurationDays = duration + } + if trialDays := intNumber(doc["trialDays"]); trialDays > 0 { + policy.TrialDays = trialDays + } + if graceDays := intNumber(doc["graceDays"]); graceDays > 0 { + policy.GraceDays = graceDays + } + return policy, nil +} + +func (a *app) ensureStripeEntitlement(ctx context.Context, userID, entitlementKey string, event stripeWebhookEvent) error { + if a.db == nil { + return errors.New("database not configured") + } + _ = ctx + active, err := a.hasActiveEntitlement(userID, entitlementKey, time.Now().UTC()) + if err != nil { + return err + } + if active { + return nil + } + meta := stripeWebhookMetadata(event) + policy, err := a.resolveBillingPricePolicy(ctx, meta.PriceID, stringValue(event.Data.Object["mode"])) + if err != nil { + return err + } + now := time.Now().UTC() + doc, err := prepareEntitlementPayload(Doc{ + "userId": userID, + "key": entitlementKey, + "status": entitlementStatusActive, + "source": "stripe_" + policy.Mode, + "startsAt": now.UnixMilli(), + "endsAt": now.Add(time.Duration(policy.EntitlementDurationDays) * 24 * time.Hour).UnixMilli(), + "scope": meta.Scope, + "tenantId": meta.TenantID, + "platformAccountId": meta.PlatformAccountID, + "sourceEventId": event.ID, + "initiatedByUserId": meta.InitiatedByUserID, + "reason": "stripe_" + strings.ReplaceAll(event.Type, ".", "_"), + "metadata": Doc{ + "stripeEventId": event.ID, + "stripeEventType": event.Type, + "priceId": meta.PriceID, + "checkoutSessionId": stringValue(event.Data.Object["id"]), + "scope": meta.Scope, + "tenantId": meta.TenantID, + "platformAccountId": meta.PlatformAccountID, + }, + }, false) + if err != nil { + return err + } + _, err = a.create("entitlements", doc) + return err +} + +func (a *app) updateEntitlementLifecycleFromBilling(ctx context.Context, patch entitlementLifecyclePatch) error { + if a.db == nil { + return errors.New("database not configured") + } + filter := Doc{ + "userId": strings.TrimSpace(patch.UserID), + "key": strings.TrimSpace(patch.Key), + } + if strings.TrimSpace(patch.Scope) != "" { + filter["scope"] = strings.TrimSpace(patch.Scope) + } + if strings.TrimSpace(patch.TenantID) != "" { + filter["tenantId"] = strings.TrimSpace(patch.TenantID) + } + docs, err := a.list("entitlements", filter, Sort{{Key: "updatedAt", Value: -1}, {Key: "createdAt", Value: -1}}) + if err != nil { + return err + } + if len(docs) == 0 { + return nil + } + updatePatch := Doc{ + "status": strings.TrimSpace(strings.ToLower(patch.Status)), + "reason": strings.TrimSpace(patch.Reason), + "sourceEventId": strings.TrimSpace(patch.SourceEventID), + "scope": strings.TrimSpace(patch.Scope), + "tenantId": strings.TrimSpace(patch.TenantID), + "platformAccountId": strings.TrimSpace(patch.PlatformAccountID), + "source": "stripe_lifecycle", + } + now := time.Now().UTC().UnixMilli() + if updatePatch["status"] == entitlementStatusRevoked { + updatePatch["revokedAt"] = now + } else if updatePatch["status"] == entitlementStatusGrace { + updatePatch["graceAt"] = now + } + _, err = a.patch("entitlements", stringValue(docs[0]["id"]), updatePatch) + return err +} + +func (a *app) validateBillingEventOwnership(ctx context.Context, metadata stripeEventMetadata) error { + if a.db == nil { + return errors.New("database not configured") + } + _ = ctx + scope := strings.TrimSpace(metadata.Scope) + if scope == "" { + scope = entitlementScopeUser + } + switch scope { + case entitlementScopeTenant: + if strings.TrimSpace(metadata.TenantID) == "" { + return errors.New("tenant scope billing event missing tenantId") + } + if !metadata.IsPlatformActor && strings.TrimSpace(metadata.InitiatedByTenantID) != "" && strings.TrimSpace(metadata.InitiatedByTenantID) != strings.TrimSpace(metadata.TenantID) { + return errors.New("cross-tenant billing event blocked") + } + case entitlementScopePlatform: + if strings.TrimSpace(metadata.PlatformAccountID) == "" { + return errors.New("platform scope billing event missing platformAccountId") + } + } + if strings.TrimSpace(metadata.BillingCustomerID) == "" { + return nil + } + customer, err := a.getByID("billing_customers", strings.TrimSpace(metadata.BillingCustomerID)) + if err != nil { + if errors.Is(err, ErrNotFound) { + return errors.New("billing customer not found for event") + } + return err + } + if customerUserID := strings.TrimSpace(anyToString(customer["userId"])); customerUserID != "" && customerUserID != strings.TrimSpace(metadata.UserID) { + return errors.New("billing customer user mismatch") + } + if customerScope := strings.TrimSpace(anyToString(customer["scope"])); customerScope != "" && customerScope != scope { + return errors.New("billing customer scope mismatch") + } + if scope == entitlementScopeTenant { + customerTenantID := strings.TrimSpace(anyToString(customer["tenantId"])) + if customerTenantID != "" && customerTenantID != strings.TrimSpace(metadata.TenantID) { + return errors.New("billing customer tenant mismatch") + } + } + return nil +} + +func (a *app) billingComplianceReport(w http.ResponseWriter, r *http.Request) { + if a.db == nil { + writeError(w, http.StatusServiceUnavailable, "database not configured") + return + } + tenantID, enforceTenant := entitlementTenantScopeFromRequest(r) + filter := Doc{} + if enforceTenant { + filter["tenantId"] = tenantID + } + entitlements, err := a.list("entitlements", filter, Sort{{Key: "updatedAt", Value: -1}}) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to load entitlements for compliance report") + return + } + events, err := a.list("billing_events", filter, Sort{{Key: "receivedAt", Value: -1}}) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to load billing events for compliance report") + return + } + statusCounts := map[string]int{} + scopeCounts := map[string]int{} + for _, doc := range entitlements { + status := strings.TrimSpace(strings.ToLower(anyToString(doc["status"]))) + if status == "" { + status = "unknown" + } + statusCounts[status]++ + scope := strings.TrimSpace(strings.ToLower(anyToString(doc["scope"]))) + if scope == "" { + scope = "unknown" + } + scopeCounts[scope]++ + } + eventStatusCounts := map[string]int{} + for _, doc := range events { + status := strings.TrimSpace(strings.ToLower(anyToString(doc["status"]))) + if status == "" { + status = "unknown" + } + eventStatusCounts[status]++ + } + writeJSON(w, http.StatusOK, Doc{ + "tenantId": tenantID, + "tenantScoped": enforceTenant, + "entitlementCount": len(entitlements), + "billingEventCount": len(events), + "entitlementByStatus": statusCounts, + "entitlementByScope": scopeCounts, + "billingEventByStatus": eventStatusCounts, + }) +} + +func (a *app) backfillBillingComplianceData(w http.ResponseWriter, r *http.Request) { + if a.db == nil { + writeError(w, http.StatusServiceUnavailable, "database not configured") + return + } + claims, _ := r.Context().Value(userClaimsKey).(*accessClaims) + actorID := "" + if claims != nil { + actorID = strings.TrimSpace(claims.Sub) + } + backfilled := map[string]int{"entitlements": 0, "billing_customers": 0, "billing_events": 0} + for _, collection := range []string{"entitlements", "billing_customers", "billing_events"} { + docs, err := a.list(collection, Doc{}, Sort{{Key: "updatedAt", Value: -1}}) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to list records for backfill") + return + } + for _, doc := range docs { + patch := Doc{} + scope := strings.TrimSpace(anyToString(doc["scope"])) + tenantID := strings.TrimSpace(anyToString(doc["tenantId"])) + if scope == "" { + if tenantID != "" { + scope = entitlementScopeTenant + } else { + scope = entitlementScopeUser + } + patch["scope"] = scope + } + if scope == entitlementScopePlatform && strings.TrimSpace(anyToString(doc["platformAccountId"])) == "" { + patch["platformAccountId"] = "platform" + } + if actorID != "" { + patch["lastUpdatedByUserId"] = actorID + } + if len(patch) == 0 { + continue + } + if _, err := a.patch(collection, stringValue(doc["id"]), patch); err != nil { + writeError(w, http.StatusInternalServerError, "failed to patch compliance backfill record") + return + } + backfilled[collection]++ + } + } + writeJSON(w, http.StatusOK, Doc{"backfilled": backfilled}) +} + +func (a *app) applyBillingRetentionPolicy(w http.ResponseWriter, r *http.Request) { + if a.db == nil { + writeError(w, http.StatusServiceUnavailable, "database not configured") + return + } + now := time.Now().UTC() + billingCutoff := now.Add(-time.Duration(a.cfg.BillingEventRetentionDays) * 24 * time.Hour).UnixMilli() + entitlementCutoff := now.Add(-time.Duration(a.cfg.EntitlementHistoryRetentionDays) * 24 * time.Hour).UnixMilli() + ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) + defer cancel() + billingCollection, err := a.resolveCollection("billing_events") + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to resolve billing events collection") + return + } + entitlementCollection, err := a.resolveCollection("entitlements") + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to resolve entitlements collection") + return + } + billingResult, err := billingCollection.UpdateMany(ctx, Doc{ + "receivedAt": Doc{"$lt": billingCutoff}, + "archivedAt": Doc{"$exists": false}, + }, Doc{"$set": Doc{"archivedAt": now.UnixMilli(), "archivedReason": "retention_policy"}}) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to archive billing events") + return + } + entitlementResult, err := entitlementCollection.UpdateMany(ctx, Doc{ + "updatedAt": Doc{"$lt": entitlementCutoff}, + "status": Doc{"$in": List{entitlementStatusRevoked, entitlementStatusExpired}}, + "archivedAt": Doc{"$exists": false}, + }, Doc{"$set": Doc{"archivedAt": now.UnixMilli(), "archivedReason": "retention_policy"}}) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to archive entitlements") + return + } + writeJSON(w, http.StatusOK, Doc{ + "billingEventsArchived": billingResult.ModifiedCount, + "entitlementsArchived": entitlementResult.ModifiedCount, + "billingCutoff": billingCutoff, + "entitlementCutoff": entitlementCutoff, + }) +} + +func (a *app) createCheckoutSession(w http.ResponseWriter, r *http.Request) { + claims, _ := r.Context().Value(userClaimsKey).(*accessClaims) + if claims == nil || strings.TrimSpace(claims.Sub) == "" { + writeError(w, http.StatusUnauthorized, "no authenticated user") + return + } + + var payload stripeCheckoutRequest + if err := decodeJSON(r, &payload); err != nil { + writeError(w, http.StatusBadRequest, "invalid payload") + return + } + + lineItems, err := normalizeStripeCheckoutLineItems(payload.PriceID, payload.LineItems) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + for _, lineItem := range lineItems { + if !isAllowedStripePriceID(a.cfg.StripeAllowedPriceIDs, lineItem.PriceID) { + writeError(w, http.StatusForbidden, "priceId is not allowed") + return + } + } + if len(lineItems) == 0 { + writeError(w, http.StatusBadRequest, "priceId is required") + return + } + + mode := normalizeStripeCheckoutMode(payload.Mode) + customerEmail := strings.TrimSpace(claims.Email) + if customerEmail == "" { + writeError(w, http.StatusBadRequest, "authenticated user email is required for checkout") + return + } + scope, err := resolveCheckoutScope(claims, payload.Scope, payload.Tenant) + if err != nil { + switch { + case errors.Is(err, errCheckoutScopeForbidden): + writeError(w, http.StatusForbidden, err.Error()) + default: + writeError(w, http.StatusBadRequest, err.Error()) + } + return + } + + service := a.activeStripeService() + var customerDoc Doc + if a.billingCustomerEnsurer != nil { + customerDoc, err = a.billingCustomerEnsurer(scope.CustomerKey, customerEmail) + } else { + customerDoc, err = a.ensureBillingCustomer(r.Context(), scope, claims, customerEmail, service) + } + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to prepare billing customer") + return + } + + metadata := map[string]string{ + "billingCustomerId": stringValue(customerDoc["id"]), + "priceId": lineItems[0].PriceID, + "priceIds": strings.Join(checkoutLineItemPriceIDs(lineItems), ","), + "scope": scope.Scope, + "isPlatformActor": strconv.FormatBool(hasAnyRole(claims, "admin", "platform_owner", "platform_admin")), + } + if scope.Scope == "user" { + metadata["userId"] = strings.TrimSpace(claims.Sub) + } + if scope.TenantID != "" { + metadata["tenantId"] = scope.TenantID + } + if initiatedByTenantID := strings.TrimSpace(resolveClaimsTenantID(claims)); initiatedByTenantID != "" { + metadata["initiatedByTenantId"] = initiatedByTenantID + } + if scope.PlatformAccountID != "" { + metadata["platformAccountId"] = scope.PlatformAccountID + } + if actorID := strings.TrimSpace(claims.Sub); actorID != "" { + metadata["initiatedByUserId"] = actorID + } + + session, err := service.CreateCheckoutSession(r.Context(), stripeCheckoutParams{ + CustomerID: stringValue(customerDoc["stripeCustomerId"]), + CustomerEmail: customerEmail, + PriceID: lineItems[0].PriceID, + LineItems: lineItems, + Mode: mode, + SuccessURL: a.cfg.StripeCheckoutSuccessURL, + CancelURL: a.cfg.StripeCheckoutCancelURL, + Metadata: metadata, + }) + if err != nil { + writeError(w, http.StatusBadGateway, "failed to create checkout session") + return + } + + writeJSON(w, http.StatusCreated, stripeCheckoutResponse{ + SessionID: session.ID, + CheckoutURL: session.URL, + }) +} + +var errCheckoutScopeForbidden = errors.New("checkout scope requires platform admin role") + +type checkoutScope struct { + Scope string + TenantID string + PlatformAccountID string + CustomerKey string +} + +func resolveCheckoutScope(claims *accessClaims, requestedScope, requestedTenantID string) (checkoutScope, error) { + if claims == nil { + return checkoutScope{}, errors.New("no authenticated user") + } + tenantID := strings.TrimSpace(resolveClaimsTenantID(claims)) + scope := strings.TrimSpace(strings.ToLower(requestedScope)) + isPlatformRole := hasAnyRole(claims, "admin", "platform_owner", "platform_admin") + if scope == "" { + if tenantID != "" { + scope = "tenant" + } else { + scope = "user" + } + } + switch scope { + case "user": + return checkoutScope{Scope: "user", CustomerKey: strings.TrimSpace(claims.Sub)}, nil + case "tenant": + targetTenantID := strings.TrimSpace(requestedTenantID) + if targetTenantID == "" { + targetTenantID = tenantID + } + if targetTenantID == "" { + return checkoutScope{}, errors.New("tenantId is required for tenant scope checkout") + } + if !isPlatformRole && tenantID != targetTenantID { + return checkoutScope{}, errCheckoutScopeForbidden + } + return checkoutScope{ + Scope: "tenant", + TenantID: targetTenantID, + PlatformAccountID: "", + CustomerKey: "tenant:" + targetTenantID, + }, nil + case "platform": + if !isPlatformRole { + return checkoutScope{}, errCheckoutScopeForbidden + } + return checkoutScope{Scope: "platform", CustomerKey: "platform", PlatformAccountID: "platform"}, nil + default: + return checkoutScope{}, errors.New("scope must be one of: user, tenant, platform") + } +} + +func (a *app) ensureBillingCustomer(ctx context.Context, scope checkoutScope, claims *accessClaims, email string, service stripeService) (Doc, error) { + if a.db == nil { + return nil, errors.New("database not configured") + } + if service == nil { + return nil, errors.New("stripe service not configured") + } + userID := strings.TrimSpace(scope.CustomerKey) + tenantID := strings.TrimSpace(scope.TenantID) + initiatedByUserID := "" + if claims != nil { + initiatedByUserID = strings.TrimSpace(claims.Sub) + } + + docs, err := a.list("billing_customers", Doc{"userId": strings.TrimSpace(userID)}, Sort{{Key: "updatedAt", Value: -1}, {Key: "createdAt", Value: -1}}) + if err != nil { + return nil, err + } + + ensureStripeCustomer := func(existing Doc) (string, error) { + stripeCustomerID := stringValue(existing["stripeCustomerId"]) + if stripeCustomerID != "" { + return stripeCustomerID, nil + } + customer, err := service.EnsureCustomer(ctx, stripeCustomerParams{ + Email: strings.TrimSpace(email), + Metadata: map[string]string{ + "userId": strings.TrimSpace(userID), + }, + }) + if err != nil { + return "", err + } + return customer.ID, nil + } + + if len(docs) > 0 { + doc := docs[0] + patch := Doc{} + currentEmail := stringValue(doc["email"]) + if strings.TrimSpace(email) != "" && currentEmail != strings.TrimSpace(email) { + patch["email"] = strings.TrimSpace(email) + } + if stringValue(doc["scope"]) != scope.Scope { + patch["scope"] = scope.Scope + } + if stringValue(doc["tenantId"]) != tenantID { + patch["tenantId"] = tenantID + } + if stringValue(doc["platformAccountId"]) != scope.PlatformAccountID { + patch["platformAccountId"] = strings.TrimSpace(scope.PlatformAccountID) + } + if initiatedByUserID != "" { + patch["lastInitiatedByUserId"] = initiatedByUserID + } + stripeCustomerID, err := ensureStripeCustomer(doc) + if err != nil { + return nil, err + } + if stringValue(doc["stripeCustomerId"]) != stripeCustomerID { + patch["stripeCustomerId"] = stripeCustomerID + } + if len(patch) == 0 { + return doc, nil + } + return a.patch("billing_customers", stringValue(doc["id"]), patch) + } + + customer, err := service.EnsureCustomer(ctx, stripeCustomerParams{ + Email: strings.TrimSpace(email), + Metadata: map[string]string{ + "userId": strings.TrimSpace(userID), + "scope": scope.Scope, + "tenantId": tenantID, + "platformAccountId": strings.TrimSpace(scope.PlatformAccountID), + }, + }) + if err != nil { + return nil, err + } + + return a.create("billing_customers", Doc{ + "userId": strings.TrimSpace(userID), + "email": strings.TrimSpace(email), + "stripeCustomerId": strings.TrimSpace(customer.ID), + "scope": scope.Scope, + "tenantId": tenantID, + "platformAccountId": strings.TrimSpace(scope.PlatformAccountID), + "lastInitiatedByUserId": initiatedByUserID, + }) +} + +func isAllowedStripePriceID(allowed []string, priceID string) bool { + if strings.TrimSpace(priceID) == "" { + return false + } + for _, candidate := range allowed { + if strings.TrimSpace(candidate) == strings.TrimSpace(priceID) { + return true + } + } + return false +} + +func normalizeStripeCheckoutLineItems(legacyPriceID string, raw []stripeCheckoutLineItem) ([]stripeCheckoutLineItem, error) { + if len(raw) == 0 { + priceID := strings.TrimSpace(legacyPriceID) + if priceID == "" { + return nil, nil + } + return []stripeCheckoutLineItem{{PriceID: priceID, Quantity: 1}}, nil + } + items := make([]stripeCheckoutLineItem, 0, len(raw)) + for _, candidate := range raw { + priceID := strings.TrimSpace(candidate.PriceID) + if priceID == "" { + return nil, errors.New("lineItems[].priceId is required") + } + quantity := candidate.Quantity + if quantity <= 0 { + return nil, errors.New("lineItems[].quantity must be greater than 0") + } + items = append(items, stripeCheckoutLineItem{ + PriceID: priceID, + Quantity: quantity, + }) + } + return items, nil +} + +func checkoutLineItemPriceIDs(lineItems []stripeCheckoutLineItem) []string { + priceIDs := make([]string, 0, len(lineItems)) + for _, lineItem := range lineItems { + priceID := strings.TrimSpace(lineItem.PriceID) + if priceID == "" { + continue + } + priceIDs = append(priceIDs, priceID) + } + return priceIDs +} + +func firstStripePriceID(priceIDs []string) string { + if len(priceIDs) == 0 { + return "" + } + return strings.TrimSpace(priceIDs[0]) +} + +func normalizeStripePriceIDs(primary string, metadataValues []string, lineItemValues []string) []string { + seen := map[string]struct{}{} + priceIDs := make([]string, 0, len(metadataValues)+len(lineItemValues)+1) + add := func(value string) { + priceID := strings.TrimSpace(value) + if priceID == "" { + return + } + if _, ok := seen[priceID]; ok { + return + } + seen[priceID] = struct{}{} + priceIDs = append(priceIDs, priceID) + } + add(primary) + for _, value := range metadataValues { + add(value) + } + for _, value := range lineItemValues { + add(value) + } + return priceIDs +} + +func stripeWebhookLineItemPriceIDs(eventObject map[string]any) []string { + lines, _ := eventObject["lines"].(map[string]any) + data, _ := lines["data"].([]any) + if len(data) == 0 { + return nil + } + priceIDs := make([]string, 0, len(data)) + for _, entry := range data { + line, _ := entry.(map[string]any) + priceMap, _ := line["price"].(map[string]any) + priceID, _ := asString(priceMap["id"]) + if strings.TrimSpace(priceID) == "" { + planMap, _ := line["plan"].(map[string]any) + priceID, _ = asString(planMap["id"]) + } + priceIDs = append(priceIDs, strings.TrimSpace(priceID)) + } + return priceIDs +} + +func normalizeStripeCheckoutMode(raw string) string { + normalized := strings.TrimSpace(strings.ToLower(raw)) + if normalized == "subscription" { + return "subscription" + } + return "payment" +} + +func stringValue(raw any) string { + value, _ := asString(raw) + return value +} + +func intNumber(raw any) int { + switch value := raw.(type) { + case int: + return value + case int32: + return int(value) + case int64: + return int(value) + case float64: + return int(value) + case float32: + return int(value) + default: + return 0 + } +} + +type stripeAPIService struct { + secretKey string + apiBaseURL string + httpClient notificationHTTPClient +} + +func newStripeService(cfg config) stripeService { + secretKey := strings.TrimSpace(cfg.StripeSecretKey) + if secretKey == "" { + return noopStripeService{} + } + baseURL := strings.TrimSpace(cfg.StripeAPIBaseURL) + if baseURL == "" { + baseURL = "https://api.stripe.com" + } + return stripeAPIService{ + secretKey: secretKey, + apiBaseURL: strings.TrimRight(baseURL, "/"), + httpClient: http.DefaultClient, + } +} + +type noopStripeService struct{} + +func (noopStripeService) EnsureCustomer(ctx context.Context, params stripeCustomerParams) (stripeCustomer, error) { + _ = ctx + identifier := strings.ReplaceAll(strings.TrimSpace(params.Email), "@", "_at_") + if identifier == "" { + identifier = "anonymous" + } + return stripeCustomer{ID: "noop-customer-" + identifier, Email: strings.TrimSpace(params.Email)}, nil +} + +func (noopStripeService) CreateCheckoutSession(ctx context.Context, params stripeCheckoutParams) (stripeCheckoutSession, error) { + _ = ctx + return stripeCheckoutSession{ + ID: "noop-checkout-session", + URL: params.SuccessURL, + }, nil +} + +func (s stripeAPIService) EnsureCustomer(ctx context.Context, params stripeCustomerParams) (stripeCustomer, error) { + form := url.Values{} + form.Set("email", strings.TrimSpace(params.Email)) + for key, value := range params.Metadata { + if strings.TrimSpace(key) == "" || strings.TrimSpace(value) == "" { + continue + } + form.Set(fmt.Sprintf("metadata[%s]", key), value) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, s.apiBaseURL+"/v1/customers", strings.NewReader(form.Encode())) + if err != nil { + return stripeCustomer{}, fmt.Errorf("create stripe customer request: %w", err) + } + req.Header.Set("Authorization", "Bearer "+s.secretKey) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + client := s.httpClient + if client == nil { + client = http.DefaultClient + } + + res, err := client.Do(req) + if err != nil { + return stripeCustomer{}, fmt.Errorf("stripe customer request failed: %w", err) + } + defer res.Body.Close() + + if res.StatusCode < 200 || res.StatusCode > 299 { + raw, _ := io.ReadAll(io.LimitReader(res.Body, 8192)) + return stripeCustomer{}, fmt.Errorf("stripe customer failed: status=%d body=%s", res.StatusCode, strings.TrimSpace(string(raw))) + } + + var payload struct { + ID string `json:"id"` + Email string `json:"email"` + } + if err := json.NewDecoder(res.Body).Decode(&payload); err != nil { + return stripeCustomer{}, fmt.Errorf("decode stripe customer response: %w", err) + } + if strings.TrimSpace(payload.ID) == "" { + return stripeCustomer{}, errors.New("stripe customer response missing id") + } + + return stripeCustomer{ID: strings.TrimSpace(payload.ID), Email: strings.TrimSpace(payload.Email)}, nil +} + +func (s stripeAPIService) CreateCheckoutSession(ctx context.Context, params stripeCheckoutParams) (stripeCheckoutSession, error) { + form := url.Values{} + form.Set("mode", normalizeStripeCheckoutMode(params.Mode)) + form.Set("success_url", params.SuccessURL) + form.Set("cancel_url", params.CancelURL) + if strings.TrimSpace(params.CustomerID) != "" { + form.Set("customer", strings.TrimSpace(params.CustomerID)) + } else { + form.Set("customer_email", params.CustomerEmail) + } + lineItems := params.LineItems + if len(lineItems) == 0 && strings.TrimSpace(params.PriceID) != "" { + lineItems = []stripeCheckoutLineItem{{PriceID: strings.TrimSpace(params.PriceID), Quantity: 1}} + } + for idx, lineItem := range lineItems { + priceID := strings.TrimSpace(lineItem.PriceID) + if priceID == "" { + continue + } + quantity := lineItem.Quantity + if quantity <= 0 { + quantity = 1 + } + form.Set(fmt.Sprintf("line_items[%d][price]", idx), priceID) + form.Set(fmt.Sprintf("line_items[%d][quantity]", idx), strconv.Itoa(quantity)) + } + for key, value := range params.Metadata { + if strings.TrimSpace(key) == "" || strings.TrimSpace(value) == "" { + continue + } + form.Set(fmt.Sprintf("metadata[%s]", key), value) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, s.apiBaseURL+"/v1/checkout/sessions", strings.NewReader(form.Encode())) + if err != nil { + return stripeCheckoutSession{}, fmt.Errorf("create stripe request: %w", err) + } + req.Header.Set("Authorization", "Bearer "+s.secretKey) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + client := s.httpClient + if client == nil { + client = http.DefaultClient + } + + res, err := client.Do(req) + if err != nil { + return stripeCheckoutSession{}, fmt.Errorf("stripe request failed: %w", err) + } + defer res.Body.Close() + + if res.StatusCode < 200 || res.StatusCode > 299 { + raw, _ := io.ReadAll(io.LimitReader(res.Body, 8192)) + return stripeCheckoutSession{}, fmt.Errorf("stripe checkout failed: status=%d body=%s", res.StatusCode, strings.TrimSpace(string(raw))) + } + + var payload struct { + ID string `json:"id"` + URL string `json:"url"` + } + if err := json.NewDecoder(res.Body).Decode(&payload); err != nil { + return stripeCheckoutSession{}, fmt.Errorf("decode stripe response: %w", err) + } + if strings.TrimSpace(payload.ID) == "" || strings.TrimSpace(payload.URL) == "" { + return stripeCheckoutSession{}, errors.New("stripe checkout response missing id or url") + } + + return stripeCheckoutSession{ID: strings.TrimSpace(payload.ID), URL: strings.TrimSpace(payload.URL)}, nil +} diff --git a/Backend/cmd/api/blog.go b/Backend/cmd/api/blog.go new file mode 100644 index 0000000..b0e6ace --- /dev/null +++ b/Backend/cmd/api/blog.go @@ -0,0 +1,507 @@ +package main + +import ( + "context" + "errors" + "net/http" + "strings" + "time" + + "github.com/go-chi/chi/v5" +) + +const ( + blogStatusDraft = "draft" + blogStatusScheduled = "scheduled" + blogStatusPublished = "published" +) + +var blogPostPublishingPolicy = blogPublishingPolicy{} + +type blogPublishingPolicy struct{} + +func (a *app) blogEditorMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + claims, _ := r.Context().Value(userClaimsKey).(*accessClaims) + if claims == nil { + writeError(w, http.StatusUnauthorized, "no authenticated user") + return + } + if !hasRole(claims, "admin") && !hasRole(claims, "editor") { + writeError(w, http.StatusForbidden, "insufficient role for blog management") + return + } + next.ServeHTTP(w, r) + }) +} + +func (a *app) listBlogPosts(w http.ResponseWriter, r *http.Request) { + a.listCollection("blog_posts", w) +} + +func (a *app) createBlogPost(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 + } + + doc, err := prepareBlogPostPayload(Doc(payload), false, nil) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + + doc, err = a.create("blog_posts", doc) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to create resource") + return + } + writeJSON(w, http.StatusCreated, doc) +} + +func (a *app) getBlogPost(w http.ResponseWriter, r *http.Request) { + a.getCollection("blog_posts", w, r) +} + +func (a *app) patchBlogPost(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 + } + + existing, err := a.getByID("blog_posts", id) + 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 + } + + doc, err := prepareBlogPostPayload(Doc(patch), true, existing) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + + doc, err = a.patch("blog_posts", id, doc) + 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) deleteBlogPost(w http.ResponseWriter, r *http.Request) { + a.deleteCollection("blog_posts", w, r) +} + +func (a *app) listBlogCategories(w http.ResponseWriter, r *http.Request) { + a.listCollection("blog_categories", w) +} + +func (a *app) createBlogCategory(w http.ResponseWriter, r *http.Request) { + a.createCollection("blog_categories", w, r) +} + +func (a *app) getBlogCategory(w http.ResponseWriter, r *http.Request) { + a.getCollection("blog_categories", w, r) +} + +func (a *app) patchBlogCategory(w http.ResponseWriter, r *http.Request) { + a.patchCollection("blog_categories", w, r) +} + +func (a *app) deleteBlogCategory(w http.ResponseWriter, r *http.Request) { + a.deleteCollection("blog_categories", w, r) +} + +func (a *app) listBlogTags(w http.ResponseWriter, r *http.Request) { + a.listCollection("blog_tags", w) +} + +func (a *app) createBlogTag(w http.ResponseWriter, r *http.Request) { + a.createCollection("blog_tags", w, r) +} + +func (a *app) getBlogTag(w http.ResponseWriter, r *http.Request) { + a.getCollection("blog_tags", w, r) +} + +func (a *app) patchBlogTag(w http.ResponseWriter, r *http.Request) { + a.patchCollection("blog_tags", w, r) +} + +func (a *app) deleteBlogTag(w http.ResponseWriter, r *http.Request) { + a.deleteCollection("blog_tags", w, r) +} + +func (a *app) listBlogAuthors(w http.ResponseWriter, r *http.Request) { + a.listCollection("blog_authors", w) +} + +func (a *app) createBlogAuthor(w http.ResponseWriter, r *http.Request) { + a.createCollection("blog_authors", w, r) +} + +func (a *app) getBlogAuthor(w http.ResponseWriter, r *http.Request) { + a.getCollection("blog_authors", w, r) +} + +func (a *app) patchBlogAuthor(w http.ResponseWriter, r *http.Request) { + a.patchCollection("blog_authors", w, r) +} + +func (a *app) deleteBlogAuthor(w http.ResponseWriter, r *http.Request) { + a.deleteCollection("blog_authors", w, r) +} + +func (a *app) listPublicBlogPosts(w http.ResponseWriter, r *http.Request) { + if a.db == nil { + writeError(w, http.StatusServiceUnavailable, "database not configured") + return + } + + docs, err := a.list("blog_posts", Doc{}, Sort{{Key: "publishedAt", Value: -1}, {Key: "updatedAt", Value: -1}}) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to list blog posts") + return + } + + now := time.Now().UTC() + posts := make([]Doc, 0, len(docs)) + for _, doc := range docs { + if isPublicBlogPost(doc, now) { + posts = append(posts, doc) + } + } + + writeJSON(w, http.StatusOK, posts) +} + +func (a *app) getPublicBlogPostBySlug(w http.ResponseWriter, r *http.Request) { + if a.db == nil { + writeError(w, http.StatusServiceUnavailable, "database not configured") + return + } + + slug := strings.TrimSpace(chi.URLParam(r, "slug")) + if slug == "" { + writeError(w, http.StatusBadRequest, "slug is required") + return + } + + ctx, cancel := context.WithTimeout(r.Context(), 8*time.Second) + defer cancel() + + var doc Doc + err := a.db.Collection("blog_posts").FindOne(ctx, Doc{"slug": slug}).Decode(&doc) + if err != nil { + if errors.Is(err, ErrNotFound) { + writeError(w, http.StatusNotFound, "blog post not found") + return + } + writeError(w, http.StatusInternalServerError, "failed to load blog post") + return + } + + normalizeDocument(doc) + if !isPublicBlogPost(doc, time.Now().UTC()) { + writeError(w, http.StatusNotFound, "blog post not found") + return + } + + writeJSON(w, http.StatusOK, doc) +} + +func (a *app) listPublicBlogCategories(w http.ResponseWriter, r *http.Request) { + if a.db == nil { + writeError(w, http.StatusServiceUnavailable, "database not configured") + return + } + posts, err := a.list("blog_posts", Doc{}, Sort{{Key: "publishedAt", Value: -1}, {Key: "updatedAt", Value: -1}}) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to load blog posts") + return + } + + allowed := collectPublicBlogTaxonomyValues(posts, "categories", time.Now().UTC()) + + docs, err := a.list("blog_categories", Doc{}, Sort{{Key: "name", Value: 1}}) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to list blog categories") + return + } + + writeJSON(w, http.StatusOK, filterBlogTaxonomyDocuments(docs, allowed)) +} + +func (a *app) listPublicBlogTags(w http.ResponseWriter, r *http.Request) { + if a.db == nil { + writeError(w, http.StatusServiceUnavailable, "database not configured") + return + } + posts, err := a.list("blog_posts", Doc{}, Sort{{Key: "publishedAt", Value: -1}, {Key: "updatedAt", Value: -1}}) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to load blog posts") + return + } + + allowed := collectPublicBlogTaxonomyValues(posts, "tags", time.Now().UTC()) + + docs, err := a.list("blog_tags", Doc{}, Sort{{Key: "name", Value: 1}}) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to list blog tags") + return + } + + writeJSON(w, http.StatusOK, filterBlogTaxonomyDocuments(docs, allowed)) +} + +func collectPublicBlogTaxonomyValues(posts []Doc, field string, now time.Time) map[string]struct{} { + allowed := make(map[string]struct{}) + + for _, post := range posts { + if !isPublicBlogPost(post, now) { + continue + } + + values, hasValues, err := normalizeStringSlice(post[field]) + if err != nil || !hasValues { + continue + } + + for _, value := range values { + normalized := normalizeTaxonomyValue(value) + if normalized == "" { + continue + } + allowed[normalized] = struct{}{} + } + } + + return allowed +} + +func filterBlogTaxonomyDocuments(docs []Doc, allowed map[string]struct{}) []Doc { + if len(allowed) == 0 { + return []Doc{} + } + + filtered := make([]Doc, 0, len(docs)) + for _, doc := range docs { + if taxonomyDocumentAllowed(doc, allowed) { + filtered = append(filtered, doc) + } + } + + return filtered +} + +func taxonomyDocumentAllowed(doc Doc, allowed map[string]struct{}) bool { + for _, field := range []string{"id", "slug", "name"} { + value, ok := asString(doc[field]) + if !ok { + continue + } + + if _, exists := allowed[normalizeTaxonomyValue(value)]; exists { + return true + } + } + + return false +} + +func normalizeTaxonomyValue(value string) string { + return strings.ToLower(strings.TrimSpace(value)) +} + +func prepareBlogPostPayload(payload Doc, partial bool, existing Doc) (Doc, error) { + if payload == nil { + payload = Doc{} + } + + title, hasTitle, err := getOptionalString(payload, "title") + if err != nil { + return nil, errors.New("title must be a string") + } + if !partial && (!hasTitle || title == "") { + return nil, errors.New("title is required") + } + if hasTitle { + payload["title"] = title + } + + if err := applyPublishingPolicy(payload, partial, existing, blogPostPublishingPolicy); err != nil { + return nil, err + } + + if slug, hasSlug, err := getOptionalString(payload, "slug"); err != nil { + return nil, errors.New("slug must be a string") + } else if hasSlug { + payload["slug"] = normalizeSlug(slug) + } + + for _, field := range []string{"excerpt", "content", "authorId"} { + value, has, err := getOptionalString(payload, field) + if err != nil { + return nil, errors.New(field + " must be a string") + } + if has { + payload[field] = value + } + } + + if categories, has, err := normalizeStringSlice(payload["categories"]); err != nil { + return nil, errors.New("categories must be an array of strings") + } else if has { + payload["categories"] = categories + } + if tags, has, err := normalizeStringSlice(payload["tags"]); err != nil { + return nil, errors.New("tags must be an array of strings") + } else if has { + payload["tags"] = tags + } + + if publishedAt, has, err := parseOptionalTime(payload["publishedAt"]); err != nil { + return nil, errors.New("publishedAt must be a unix milliseconds number or RFC3339 string") + } else if has { + payload["publishedAt"] = publishedAt.UnixMilli() + } + + return payload, nil +} + +func (blogPublishingPolicy) defaultStatus() string { + return blogStatusDraft +} + +func (blogPublishingPolicy) isAllowedStatus(status string) bool { + return isValidBlogStatus(status) +} + +func (blogPublishingPolicy) invalidStatusMessage() string { + return "status must be one of: draft, scheduled, published" +} + +func (blogPublishingPolicy) validate(payload Doc, existing Doc) error { + if status, ok := mergedPublishingString(payload, existing, "status"); ok && status == blogStatusScheduled { + if _, has := mergedPublishingValue(payload, existing, "publishedAt"); !has { + return errors.New("publishedAt is required when status is scheduled") + } + } + + if status, ok := mergedPublishingString(payload, existing, "status"); ok && status == blogStatusPublished { + if _, has := mergedPublishingString(payload, existing, "slug"); !has { + return errors.New("slug is required when status is published") + } + } + + return nil +} + +func isPublicBlogPost(post Doc, now time.Time) bool { + status, ok := post["status"].(string) + normalizedStatus := strings.TrimSpace(strings.ToLower(status)) + if !ok || (normalizedStatus != blogStatusPublished && normalizedStatus != blogStatusScheduled) { + return false + } + + slug, ok := asString(post["slug"]) + if !ok || slug == "" { + return false + } + + if private, ok := asBool(post["private"]); ok && private { + return false + } + if visibility, ok := asString(post["visibility"]); ok && strings.EqualFold(visibility, "private") { + return false + } + + publishedAt, hasPublishedAt, err := parseOptionalTime(post["publishedAt"]) + if err == nil && hasPublishedAt { + return !publishedAt.After(now) + } + if normalizedStatus == blogStatusScheduled { + return false + } + + return true +} + +func isValidBlogStatus(status string) bool { + switch strings.TrimSpace(strings.ToLower(status)) { + case blogStatusDraft, blogStatusScheduled, blogStatusPublished: + return true + default: + return false + } +} + +func normalizeStringSlice(raw any) ([]string, bool, error) { + if raw == nil { + return nil, false, nil + } + + switch values := raw.(type) { + case []string: + out := make([]string, 0, len(values)) + for _, value := range values { + trimmed := strings.TrimSpace(value) + if trimmed != "" { + out = append(out, trimmed) + } + } + return out, true, nil + case List: + out := make([]string, 0, len(values)) + for _, value := range values { + text, ok := value.(string) + if !ok { + return nil, false, errors.New("array must contain strings") + } + trimmed := strings.TrimSpace(text) + if trimmed != "" { + out = append(out, trimmed) + } + } + return out, true, nil + case []any: + out := make([]string, 0, len(values)) + for _, value := range values { + text, ok := value.(string) + if !ok { + return nil, false, errors.New("array must contain strings") + } + trimmed := strings.TrimSpace(text) + if trimmed != "" { + out = append(out, trimmed) + } + } + return out, true, nil + default: + return nil, false, errors.New("not an array") + } +} + +func parseOptionalTime(raw any) (time.Time, bool, error) { + if raw == nil { + return time.Time{}, false, nil + } + + if parsed, ok := asTime(raw); ok { + return parsed.UTC(), true, nil + } + + return time.Time{}, false, errors.New("invalid time") +} diff --git a/Backend/cmd/api/communities.go b/Backend/cmd/api/communities.go new file mode 100644 index 0000000..f87b266 --- /dev/null +++ b/Backend/cmd/api/communities.go @@ -0,0 +1,841 @@ +package main + +import ( + "errors" + "net/http" + "strings" + "time" + + "github.com/go-chi/chi/v5" +) + +const ( + communityStatusActive = "active" + communityStatusArchived = "archived" + communityVisibilityPublic = "public" + communityVisibilityPrivate = "private" + communityAccessOpen = "open" + communityAccessRole = "role" + communityAccessEntitlement = "entitlement" + communityAccessMembership = "membership" +) + +func (a *app) communitiesAdminMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + claims, _ := r.Context().Value(userClaimsKey).(*accessClaims) + if claims == nil { + writeError(w, http.StatusUnauthorized, "no authenticated user") + return + } + if !hasRole(claims, "admin") { + writeError(w, http.StatusForbidden, "insufficient role for community management") + return + } + next.ServeHTTP(w, r) + }) +} + +func (a *app) communityLearnerMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + claims, _ := r.Context().Value(userClaimsKey).(*accessClaims) + if claims == nil || strings.TrimSpace(claims.Sub) == "" { + writeError(w, http.StatusUnauthorized, "no authenticated user") + return + } + if !hasRole(claims, "learner") && !hasRole(claims, "admin") && !hasRole(claims, "instructor") { + writeError(w, http.StatusForbidden, "insufficient role for community access") + return + } + next.ServeHTTP(w, r) + }) +} + +func (a *app) listCommunities(w http.ResponseWriter, r *http.Request) { + if a.db == nil { + writeError(w, http.StatusServiceUnavailable, "database not configured") + return + } + filter := Doc{} + if status := strings.TrimSpace(strings.ToLower(r.URL.Query().Get("status"))); status != "" { + filter["status"] = status + } + docs, err := a.list("communities", filter, Sort{{Key: "updatedAt", Value: -1}, {Key: "createdAt", Value: -1}}) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to list communities") + return + } + writeJSON(w, http.StatusOK, docs) +} + +func (a *app) createCommunity(w http.ResponseWriter, r *http.Request) { + if a.db == nil { + writeError(w, http.StatusServiceUnavailable, "database not configured") + return + } + var payload map[string]any + if err := decodeJSON(r, &payload); err != nil { + writeError(w, http.StatusBadRequest, "invalid payload") + return + } + doc, err := prepareCommunityPayload(Doc(payload), false) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + doc, err = a.create("communities", doc) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to create community") + return + } + writeJSON(w, http.StatusCreated, doc) +} + +func (a *app) getCommunity(w http.ResponseWriter, r *http.Request) { + a.getCollection("communities", w, r) +} + +func (a *app) patchCommunity(w http.ResponseWriter, r *http.Request) { + if a.db == nil { + writeError(w, http.StatusServiceUnavailable, "database not configured") + return + } + id := strings.TrimSpace(chi.URLParam(r, "id")) + if id == "" { + writeError(w, http.StatusBadRequest, "community id is required") + return + } + var payload map[string]any + if err := decodeJSON(r, &payload); err != nil { + writeError(w, http.StatusBadRequest, "invalid patch payload") + return + } + patch, err := prepareCommunityPayload(Doc(payload), true) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + doc, err := a.patch("communities", id, patch) + if err != nil { + if errors.Is(err, ErrNotFound) { + writeError(w, http.StatusNotFound, "resource not found") + return + } + writeError(w, http.StatusInternalServerError, "failed to update community") + return + } + writeJSON(w, http.StatusOK, doc) +} + +func (a *app) deleteCommunity(w http.ResponseWriter, r *http.Request) { + a.deleteCollection("communities", w, r) +} + +func (a *app) listCommunityChannels(w http.ResponseWriter, r *http.Request) { + communityID := strings.TrimSpace(chi.URLParam(r, "id")) + if communityID == "" { + writeError(w, http.StatusBadRequest, "community id is required") + return + } + if _, err := a.getByID("communities", communityID); err != nil { + if errors.Is(err, ErrNotFound) { + writeError(w, http.StatusNotFound, "community not found") + return + } + writeError(w, http.StatusInternalServerError, "failed to load community") + return + } + docs, err := a.list("community_channels", Doc{"communityId": communityID}, Sort{{Key: "createdAt", Value: 1}, {Key: "updatedAt", Value: -1}}) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to list community channels") + return + } + writeJSON(w, http.StatusOK, docs) +} + +func (a *app) createCommunityChannel(w http.ResponseWriter, r *http.Request) { + communityID := strings.TrimSpace(chi.URLParam(r, "id")) + if communityID == "" { + writeError(w, http.StatusBadRequest, "community id is required") + return + } + if _, err := a.getByID("communities", communityID); err != nil { + if errors.Is(err, ErrNotFound) { + writeError(w, http.StatusNotFound, "community not found") + return + } + writeError(w, http.StatusInternalServerError, "failed to load community") + return + } + var payload map[string]any + if err := decodeJSON(r, &payload); err != nil { + writeError(w, http.StatusBadRequest, "invalid payload") + return + } + doc, err := prepareCommunityChannelPayload(communityID, Doc(payload), false) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + doc, err = a.create("community_channels", doc) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to create channel") + return + } + writeJSON(w, http.StatusCreated, doc) +} + +func (a *app) getCommunityChannel(w http.ResponseWriter, r *http.Request) { + channel, err := a.getCommunityChannelByID(chi.URLParam(r, "id"), chi.URLParam(r, "channelId")) + if err != nil { + if errors.Is(err, ErrNotFound) { + writeError(w, http.StatusNotFound, "resource not found") + return + } + writeError(w, http.StatusInternalServerError, "failed to load channel") + return + } + writeJSON(w, http.StatusOK, channel) +} + +func (a *app) patchCommunityChannel(w http.ResponseWriter, r *http.Request) { + channel, err := a.getCommunityChannelByID(chi.URLParam(r, "id"), chi.URLParam(r, "channelId")) + if err != nil { + if errors.Is(err, ErrNotFound) { + writeError(w, http.StatusNotFound, "resource not found") + return + } + writeError(w, http.StatusInternalServerError, "failed to load channel") + return + } + var payload map[string]any + if err := decodeJSON(r, &payload); err != nil { + writeError(w, http.StatusBadRequest, "invalid patch payload") + return + } + patch, err := prepareCommunityChannelPayload(stringValue(channel["communityId"]), Doc(payload), true) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + doc, err := a.patch("community_channels", stringValue(channel["id"]), patch) + if err != nil { + if errors.Is(err, ErrNotFound) { + writeError(w, http.StatusNotFound, "resource not found") + return + } + writeError(w, http.StatusInternalServerError, "failed to update channel") + return + } + writeJSON(w, http.StatusOK, doc) +} + +func (a *app) deleteCommunityChannel(w http.ResponseWriter, r *http.Request) { + channel, err := a.getCommunityChannelByID(chi.URLParam(r, "id"), chi.URLParam(r, "channelId")) + if err != nil { + if errors.Is(err, ErrNotFound) { + writeError(w, http.StatusNotFound, "resource not found") + return + } + writeError(w, http.StatusInternalServerError, "failed to load channel") + return + } + if err := a.deleteByID("community_channels", stringValue(channel["id"])); err != nil { + if errors.Is(err, ErrNotFound) { + writeError(w, http.StatusNotFound, "resource not found") + return + } + writeError(w, http.StatusInternalServerError, "failed to delete channel") + return + } + w.WriteHeader(http.StatusNoContent) +} + +func (a *app) listCommunityMemberships(w http.ResponseWriter, r *http.Request) { + communityID := strings.TrimSpace(chi.URLParam(r, "id")) + if communityID == "" { + writeError(w, http.StatusBadRequest, "community id is required") + return + } + docs, err := a.list("community_memberships", Doc{"communityId": communityID}, Sort{{Key: "updatedAt", Value: -1}, {Key: "createdAt", Value: -1}}) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to list memberships") + return + } + writeJSON(w, http.StatusOK, docs) +} + +func (a *app) createCommunityMembership(w http.ResponseWriter, r *http.Request) { + communityID := strings.TrimSpace(chi.URLParam(r, "id")) + if communityID == "" { + writeError(w, http.StatusBadRequest, "community id is required") + return + } + if _, err := a.getByID("communities", communityID); err != nil { + if errors.Is(err, ErrNotFound) { + writeError(w, http.StatusNotFound, "community not found") + return + } + writeError(w, http.StatusInternalServerError, "failed to load community") + return + } + var payload map[string]any + if err := decodeJSON(r, &payload); err != nil { + writeError(w, http.StatusBadRequest, "invalid payload") + return + } + doc, err := prepareCommunityMembershipPayload(communityID, Doc(payload), false) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + doc, err = a.create("community_memberships", doc) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to create membership") + return + } + writeJSON(w, http.StatusCreated, doc) +} + +func (a *app) patchCommunityMembership(w http.ResponseWriter, r *http.Request) { + membership, err := a.getCommunityMembershipByID(chi.URLParam(r, "id"), chi.URLParam(r, "membershipId")) + if err != nil { + if errors.Is(err, ErrNotFound) { + writeError(w, http.StatusNotFound, "resource not found") + return + } + writeError(w, http.StatusInternalServerError, "failed to load membership") + return + } + var payload map[string]any + if err := decodeJSON(r, &payload); err != nil { + writeError(w, http.StatusBadRequest, "invalid patch payload") + return + } + patch, err := prepareCommunityMembershipPayload(stringValue(membership["communityId"]), Doc(payload), true) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + doc, err := a.patch("community_memberships", stringValue(membership["id"]), patch) + if err != nil { + if errors.Is(err, ErrNotFound) { + writeError(w, http.StatusNotFound, "resource not found") + return + } + writeError(w, http.StatusInternalServerError, "failed to update membership") + return + } + writeJSON(w, http.StatusOK, doc) +} + +func (a *app) listLearnerCommunities(w http.ResponseWriter, r *http.Request) { + claims, _ := r.Context().Value(userClaimsKey).(*accessClaims) + if claims == nil || strings.TrimSpace(claims.Sub) == "" { + writeError(w, http.StatusUnauthorized, "no authenticated user") + return + } + if a.db == nil { + writeError(w, http.StatusServiceUnavailable, "database not configured") + return + } + communities, err := a.list("communities", Doc{"status": communityStatusActive}, Sort{{Key: "updatedAt", Value: -1}, {Key: "createdAt", Value: -1}}) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to list communities") + return + } + out := make([]Doc, 0, len(communities)) + for _, community := range communities { + allowed, reason, err := a.canAccessCommunityForClaims(claims, community) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to evaluate community access") + return + } + if !allowed { + continue + } + channels, err := a.list("community_channels", Doc{"communityId": stringValue(community["id"])}, Sort{{Key: "createdAt", Value: 1}, {Key: "updatedAt", Value: -1}}) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to list channels") + return + } + membership, _ := a.findCommunityMembership(stringValue(community["id"]), claims.Sub) + item := Doc{ + "community": community, + "channels": channels, + "access": Doc{ + "allowed": allowed, + "reason": reason, + }, + } + if membership != nil { + item["membership"] = membership + } + out = append(out, item) + } + writeJSON(w, http.StatusOK, out) +} + +func (a *app) listLearnerCommunityChannels(w http.ResponseWriter, r *http.Request) { + claims, _ := r.Context().Value(userClaimsKey).(*accessClaims) + if claims == nil || strings.TrimSpace(claims.Sub) == "" { + writeError(w, http.StatusUnauthorized, "no authenticated user") + return + } + communityID := strings.TrimSpace(chi.URLParam(r, "id")) + if communityID == "" { + writeError(w, http.StatusBadRequest, "community id is required") + return + } + community, err := a.getByID("communities", communityID) + if err != nil { + if errors.Is(err, ErrNotFound) { + writeError(w, http.StatusNotFound, "community not found") + return + } + writeError(w, http.StatusInternalServerError, "failed to load community") + return + } + allowed, _, err := a.canAccessCommunityForClaims(claims, community) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to evaluate community access") + return + } + if !allowed { + writeError(w, http.StatusForbidden, "community access denied") + return + } + docs, err := a.list("community_channels", Doc{"communityId": communityID}, Sort{{Key: "createdAt", Value: 1}, {Key: "updatedAt", Value: -1}}) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to list channels") + return + } + writeJSON(w, http.StatusOK, docs) +} + +func (a *app) joinLearnerCommunity(w http.ResponseWriter, r *http.Request) { + claims, _ := r.Context().Value(userClaimsKey).(*accessClaims) + if claims == nil || strings.TrimSpace(claims.Sub) == "" { + writeError(w, http.StatusUnauthorized, "no authenticated user") + return + } + communityID := strings.TrimSpace(chi.URLParam(r, "id")) + if communityID == "" { + writeError(w, http.StatusBadRequest, "community id is required") + return + } + community, err := a.getByID("communities", communityID) + if err != nil { + if errors.Is(err, ErrNotFound) { + writeError(w, http.StatusNotFound, "community not found") + return + } + writeError(w, http.StatusInternalServerError, "failed to load community") + return + } + allowed, _, err := a.canAccessCommunityForClaims(claims, community) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to evaluate community access") + return + } + if !allowed { + writeError(w, http.StatusForbidden, "community access denied") + return + } + existing, _ := a.findCommunityMembership(communityID, claims.Sub) + if existing != nil { + writeJSON(w, http.StatusOK, existing) + return + } + doc, err := prepareCommunityMembershipPayload(communityID, Doc{ + "userId": claims.Sub, + "role": "member", + "status": "active", + "source": a.resolveCommunityJoinSource(claims, community), + }, false) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + doc, err = a.create("community_memberships", doc) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to join community") + return + } + writeJSON(w, http.StatusCreated, doc) +} + +func (a *app) resolveCommunityJoinSource(claims *accessClaims, community Doc) string { + if hasRole(claims, "admin") { + return "admin_override" + } + requiredEntitlement, _ := asString(community["requiredEntitlementKey"]) + if strings.TrimSpace(requiredEntitlement) != "" { + return "entitlement" + } + roles := communityAllowedRoles(community) + for _, role := range roles { + if hasRole(claims, role) { + return "role" + } + } + return "self_join" +} + +func (a *app) canAccessCommunityForClaims(claims *accessClaims, community Doc) (bool, string, error) { + if claims == nil || strings.TrimSpace(claims.Sub) == "" { + return false, "missing_user", nil + } + if hasRole(claims, "admin") { + return true, "admin", nil + } + status, _ := asString(community["status"]) + if status == communityStatusArchived { + return false, "archived", nil + } + visibility, _ := asString(community["visibility"]) + if strings.TrimSpace(visibility) == "" { + visibility = communityVisibilityPrivate + } + if visibility == communityVisibilityPublic { + return true, "public", nil + } + if membership, _ := a.findCommunityMembership(stringValue(community["id"]), claims.Sub); membership != nil { + if strings.EqualFold(stringValue(membership["status"]), "active") { + return true, "membership", nil + } + } + for _, role := range communityAllowedRoles(community) { + if hasRole(claims, role) { + return true, "role", nil + } + } + requiredEntitlement, _ := asString(community["requiredEntitlementKey"]) + requiredEntitlement = strings.TrimSpace(requiredEntitlement) + if requiredEntitlement != "" { + allowed, err := a.hasActiveEntitlement(claims.Sub, requiredEntitlement, time.Now().UTC()) + if err != nil { + return false, "entitlement_error", err + } + if allowed { + return true, "entitlement", nil + } + } + accessPolicy, _ := asString(community["accessPolicy"]) + if accessPolicy == communityAccessOpen { + return true, "open", nil + } + return false, "membership_required", nil +} + +func communityAllowedRoles(community Doc) []string { + raw, exists := community["allowedRoles"] + if !exists || raw == nil { + return []string{} + } + values, _, err := normalizeStringSlice(raw) + if err != nil { + return []string{} + } + out := make([]string, 0, len(values)) + for _, value := range values { + if strings.TrimSpace(value) == "" { + continue + } + out = append(out, strings.ToLower(strings.TrimSpace(value))) + } + return out +} + +func (a *app) findCommunityMembership(communityID, userID string) (Doc, error) { + docs, err := a.list("community_memberships", Doc{"communityId": strings.TrimSpace(communityID), "userId": strings.TrimSpace(userID)}, Sort{{Key: "updatedAt", Value: -1}, {Key: "createdAt", Value: -1}}) + if err != nil { + return nil, err + } + if len(docs) == 0 { + return nil, nil + } + return docs[0], nil +} + +func (a *app) getCommunityChannelByID(communityID, channelID string) (Doc, error) { + channel, err := a.getByID("community_channels", strings.TrimSpace(channelID)) + if err != nil { + return nil, err + } + if strings.TrimSpace(stringValue(channel["communityId"])) != strings.TrimSpace(communityID) { + return nil, ErrNotFound + } + return channel, nil +} + +func (a *app) getCommunityMembershipByID(communityID, membershipID string) (Doc, error) { + membership, err := a.getByID("community_memberships", strings.TrimSpace(membershipID)) + if err != nil { + return nil, err + } + if strings.TrimSpace(stringValue(membership["communityId"])) != strings.TrimSpace(communityID) { + return nil, ErrNotFound + } + return membership, nil +} + +func prepareCommunityPayload(payload Doc, partial bool) (Doc, error) { + if payload == nil { + payload = Doc{} + } + name, hasName, err := getOptionalString(payload, "name") + if err != nil { + return nil, errors.New("name must be a string") + } + if !partial && (!hasName || name == "") { + return nil, errors.New("name is required") + } + if hasName { + payload["name"] = name + } + + slug, hasSlug, err := getOptionalString(payload, "slug") + if err != nil { + return nil, errors.New("slug must be a string") + } + if !hasSlug && hasName { + slug = slugify(name) + hasSlug = true + } + if hasSlug { + payload["slug"] = normalizeSlug(slug) + } + + if description, has, err := getOptionalString(payload, "description"); err != nil { + return nil, errors.New("description must be a string") + } else if has { + payload["description"] = description + } + + status, hasStatus, err := getOptionalString(payload, "status") + if err != nil { + return nil, errors.New("status must be a string") + } + if !partial && !hasStatus { + status = communityStatusActive + hasStatus = true + } + if hasStatus { + status = strings.ToLower(strings.TrimSpace(status)) + switch status { + case communityStatusActive, communityStatusArchived: + payload["status"] = status + default: + return nil, errors.New("status must be one of: active, archived") + } + } + + visibility, hasVisibility, err := getOptionalString(payload, "visibility") + if err != nil { + return nil, errors.New("visibility must be a string") + } + if !partial && !hasVisibility { + visibility = communityVisibilityPrivate + hasVisibility = true + } + if hasVisibility { + visibility = strings.ToLower(strings.TrimSpace(visibility)) + switch visibility { + case communityVisibilityPublic, communityVisibilityPrivate: + payload["visibility"] = visibility + default: + return nil, errors.New("visibility must be one of: public, private") + } + } + + accessPolicy, hasPolicy, err := getOptionalString(payload, "accessPolicy") + if err != nil { + return nil, errors.New("accessPolicy must be a string") + } + if !partial && !hasPolicy { + accessPolicy = communityAccessMembership + hasPolicy = true + } + if hasPolicy { + accessPolicy = strings.ToLower(strings.TrimSpace(accessPolicy)) + switch accessPolicy { + case communityAccessOpen, communityAccessRole, communityAccessEntitlement, communityAccessMembership: + payload["accessPolicy"] = accessPolicy + default: + return nil, errors.New("accessPolicy must be one of: open, role, entitlement, membership") + } + } + + if allowedRoles, has, err := normalizeStringSlice(payload["allowedRoles"]); err != nil { + return nil, errors.New("allowedRoles must be an array of strings") + } else if has { + roles := make([]string, 0, len(allowedRoles)) + for _, role := range allowedRoles { + normalized := strings.ToLower(strings.TrimSpace(role)) + if normalized != "" { + roles = append(roles, normalized) + } + } + payload["allowedRoles"] = roles + } + + requiredEntitlementKey, hasEntitlement, err := getOptionalString(payload, "requiredEntitlementKey") + if err != nil { + return nil, errors.New("requiredEntitlementKey must be a string") + } + if hasEntitlement { + payload["requiredEntitlementKey"] = requiredEntitlementKey + } + + if metadata, exists := payload["metadata"]; exists && metadata != nil { + value, ok := asMap(metadata) + if !ok { + return nil, errors.New("metadata must be an object") + } + payload["metadata"] = Doc(value) + } + + currentPolicy, _ := asString(payload["accessPolicy"]) + if strings.TrimSpace(currentPolicy) == communityAccessRole { + roles, _, _ := normalizeStringSlice(payload["allowedRoles"]) + if len(roles) == 0 { + return nil, errors.New("allowedRoles is required when accessPolicy=role") + } + } + if strings.TrimSpace(currentPolicy) == communityAccessEntitlement { + required, _ := asString(payload["requiredEntitlementKey"]) + if strings.TrimSpace(required) == "" { + return nil, errors.New("requiredEntitlementKey is required when accessPolicy=entitlement") + } + } + + return payload, nil +} + +func prepareCommunityChannelPayload(communityID string, payload Doc, partial bool) (Doc, error) { + if payload == nil { + payload = Doc{} + } + payload["communityId"] = strings.TrimSpace(communityID) + + name, hasName, err := getOptionalString(payload, "name") + if err != nil { + return nil, errors.New("name must be a string") + } + if !partial && (!hasName || name == "") { + return nil, errors.New("name is required") + } + if hasName { + payload["name"] = name + } + + slug, hasSlug, err := getOptionalString(payload, "slug") + if err != nil { + return nil, errors.New("slug must be a string") + } + if !hasSlug && hasName { + slug = slugify(name) + hasSlug = true + } + if hasSlug { + payload["slug"] = normalizeSlug(slug) + } + + if topic, has, err := getOptionalString(payload, "topic"); err != nil { + return nil, errors.New("topic must be a string") + } else if has { + payload["topic"] = topic + } + + if isPrivate, has, err := getOptionalBool(payload, "isPrivate"); err != nil { + return nil, errors.New("isPrivate must be a boolean") + } else if has { + payload["isPrivate"] = isPrivate + } else if !partial { + payload["isPrivate"] = false + } + + return payload, nil +} + +func prepareCommunityMembershipPayload(communityID string, payload Doc, partial bool) (Doc, error) { + if payload == nil { + payload = Doc{} + } + payload["communityId"] = strings.TrimSpace(communityID) + + userID, hasUserID, err := getOptionalString(payload, "userId") + if err != nil { + return nil, errors.New("userId must be a string") + } + if !partial && (!hasUserID || userID == "") { + return nil, errors.New("userId is required") + } + if hasUserID { + payload["userId"] = userID + } + + status, hasStatus, err := getOptionalString(payload, "status") + if err != nil { + return nil, errors.New("status must be a string") + } + if !partial && !hasStatus { + status = "active" + hasStatus = true + } + if hasStatus { + normalized := strings.ToLower(strings.TrimSpace(status)) + switch normalized { + case "active", "pending", "banned", "left": + payload["status"] = normalized + default: + return nil, errors.New("status must be one of: active, pending, banned, left") + } + } + + role, hasRoleValue, err := getOptionalString(payload, "role") + if err != nil { + return nil, errors.New("role must be a string") + } + if !partial && !hasRoleValue { + role = "member" + hasRoleValue = true + } + if hasRoleValue { + normalized := strings.ToLower(strings.TrimSpace(role)) + switch normalized { + case "member", "moderator", "owner": + payload["role"] = normalized + default: + return nil, errors.New("role must be one of: member, moderator, owner") + } + } + + source, hasSource, err := getOptionalString(payload, "source") + if err != nil { + return nil, errors.New("source must be a string") + } + if !partial && !hasSource { + source = "manual" + hasSource = true + } + if hasSource { + normalized := strings.ToLower(strings.TrimSpace(source)) + switch normalized { + case "manual", "self_join", "role", "entitlement", "admin_override": + payload["source"] = normalized + default: + return nil, errors.New("source must be one of: manual, self_join, role, entitlement, admin_override") + } + } + + if metadata, exists := payload["metadata"]; exists && metadata != nil { + value, ok := asMap(metadata) + if !ok { + return nil, errors.New("metadata must be an object") + } + payload["metadata"] = Doc(value) + } + + return payload, nil +} diff --git a/Backend/cmd/api/compliance.go b/Backend/cmd/api/compliance.go new file mode 100644 index 0000000..d9c91ef --- /dev/null +++ b/Backend/cmd/api/compliance.go @@ -0,0 +1,529 @@ +package main + +import ( + "context" + "net/http" + "strings" + "time" +) + +const ( + dataSubjectRequestTypeCorrection = "correction" + dataSubjectRequestTypeRestriction = "restriction" + dataSubjectRequestTypeErasure = "erasure" + dataSubjectRequestStatusPending = "pending" + dataSubjectRequestStatusCompleted = "completed" +) + +func (a *app) educationalRecordsMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + claims, _ := r.Context().Value(userClaimsKey).(*accessClaims) + if claims == nil { + writeError(w, http.StatusUnauthorized, "no authenticated user") + return + } + if !hasAnyRole(claims, "admin", "instructor", "platform_owner", "platform_admin") { + writeError(w, http.StatusForbidden, "insufficient role for educational records") + return + } + next.ServeHTTP(w, r) + }) +} + +func (a *app) educationalComplianceReport(w http.ResponseWriter, r *http.Request) { + if a.db == nil { + writeError(w, http.StatusServiceUnavailable, "database not configured") + return + } + + tenantID, enforceTenant := entitlementTenantScopeFromRequest(r) + filter := Doc{} + if enforceTenant { + filter["tenantId"] = tenantID + } + + users, err := a.list("users", filter, nil) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to load users for compliance report") + return + } + enrollments, err := a.list("enrollments", filter, nil) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to load enrollments for compliance report") + return + } + auditEvents, err := a.list("audit_events", filter, nil) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to load audit events for compliance report") + return + } + + writeJSON(w, http.StatusOK, Doc{ + "scope": "GDPR + FERPA-style educational records controls", + "tenantId": tenantID, + "tenantScoped": enforceTenant, + "classification": Doc{ + "identityData": Doc{ + "collection": "users", + "fields": []string{"id", "email", "name", "username", "roles", "tenantId"}, + "count": len(users), + }, + "educationalRecords": Doc{ + "collection": "enrollments", + "fields": []string{"userId", "courseId", "status", "progress", "score", "completedAt"}, + "count": len(enrollments), + }, + "operationalAudit": Doc{ + "collection": "audit_events", + "fields": []string{"actor", "action", "resource", "result", "timestamp", "metadata"}, + "count": len(auditEvents), + }, + }, + "governance": Doc{ + "purposeLimitation": "Educational records are used for training delivery, progress tracking, certification, and support.", + "minimumNecessary": "Endpoints should only return fields required for authorized operational use.", + "retentionPolicy": Doc{ + "educationalRecordRetentionDays": a.cfg.EducationalRecordRetentionDays, + }, + }, + }) +} + +func (a *app) applyEducationalRetentionPolicy(w http.ResponseWriter, r *http.Request) { + if a.db == nil { + writeError(w, http.StatusServiceUnavailable, "database not configured") + return + } + + now := time.Now().UTC() + cutoff := now.Add(-time.Duration(a.cfg.EducationalRecordRetentionDays) * 24 * time.Hour).UnixMilli() + + ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) + defer cancel() + + enrollmentCollection, err := a.resolveCollection("enrollments") + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to resolve enrollments collection") + return + } + requestCollection, err := a.resolveCollection("data_subject_requests") + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to resolve data subject requests collection") + return + } + + enrollmentResult, err := enrollmentCollection.UpdateMany(ctx, Doc{ + "updatedAt": Doc{"$lt": cutoff}, + "status": Doc{"$in": List{"completed", "failed"}}, + "archivedAt": Doc{"$exists": false}, + }, Doc{ + "$set": Doc{ + "archivedAt": now.UnixMilli(), + "archivedReason": "educational_retention_policy", + "dataMinimized": true, + }, + "$unset": Doc{ + "score": "", + "progress": "", + "timeSpentMin": "", + }, + }) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to apply educational retention policy") + return + } + + requestResult, err := requestCollection.UpdateMany(ctx, Doc{ + "updatedAt": Doc{"$lt": cutoff}, + "status": dataSubjectRequestStatusCompleted, + "archivedAt": Doc{"$exists": false}, + }, Doc{"$set": Doc{ + "archivedAt": now.UnixMilli(), + "archivedReason": "educational_retention_policy", + }}) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to apply data subject request retention policy") + return + } + + writeJSON(w, http.StatusOK, Doc{ + "enrollmentsArchived": enrollmentResult.ModifiedCount, + "dataSubjectRequestsArchived": requestResult.ModifiedCount, + "cutoff": cutoff, + }) +} + +func (a *app) exportMyDataSubjectPackage(w http.ResponseWriter, r *http.Request) { + claims, ok := claimsFromRequest(r) + if !ok { + writeError(w, http.StatusUnauthorized, "no authenticated user") + return + } + if a.db == nil { + writeError(w, http.StatusServiceUnavailable, "database not configured") + return + } + + userID := strings.TrimSpace(claims.Sub) + if userID == "" { + writeError(w, http.StatusBadRequest, "authenticated subject is required") + return + } + + tenantID, enforceTenant := entitlementTenantScopeFromRequest(r) + filter := Doc{"userId": userID} + if enforceTenant { + filter["tenantId"] = tenantID + } + + users, err := a.list("users", Doc{"id": userID}, nil) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to export user profile") + return + } + enrollments, err := a.list("enrollments", filter, Sort{{Key: "updatedAt", Value: -1}}) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to export educational records") + return + } + consentReceipts, err := a.list("consent_receipts", filter, Sort{{Key: "decidedAt", Value: -1}}) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to export consent receipts") + return + } + requests, err := a.list("data_subject_requests", filter, Sort{{Key: "updatedAt", Value: -1}}) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to export data subject requests") + return + } + + a.recordAuditEvent(r.Context(), claims, "compliance.data_subject.export", "data_subject", auditResultSuccess, map[string]any{ + "tenantScoped": enforceTenant, + "recordTypes": []string{"users", "enrollments", "consent_receipts", "data_subject_requests"}, + }) + + writeJSON(w, http.StatusOK, Doc{ + "subject": userID, + "scope": "GDPR + FERPA-style educational records controls", + "exportedAt": time.Now().UTC().Format(time.RFC3339), + "data": Doc{ + "identity": users, + "educationalRecords": enrollments, + "consentReceipts": consentReceipts, + "requests": requests, + }, + }) +} + +func (a *app) submitMyDataCorrectionRequest(w http.ResponseWriter, r *http.Request) { + a.submitMyDataSubjectRequest(w, r, dataSubjectRequestTypeCorrection) +} + +func (a *app) submitMyDataRestrictionRequest(w http.ResponseWriter, r *http.Request) { + a.submitMyDataSubjectRequest(w, r, dataSubjectRequestTypeRestriction) +} + +func (a *app) submitMyDataSubjectRequest(w http.ResponseWriter, r *http.Request, requestType string) { + claims, ok := claimsFromRequest(r) + if !ok { + writeError(w, http.StatusUnauthorized, "no authenticated user") + return + } + if a.db == nil { + writeError(w, http.StatusServiceUnavailable, "database not configured") + return + } + + var payload map[string]any + if err := decodeJSON(r, &payload); err != nil { + writeError(w, http.StatusBadRequest, "invalid request payload") + return + } + + subjectID := strings.TrimSpace(claims.Sub) + if subjectID == "" { + writeError(w, http.StatusBadRequest, "authenticated subject is required") + return + } + tenantID, _ := entitlementTenantScopeFromRequest(r) + + requestDoc := Doc{ + "userId": subjectID, + "tenantId": tenantID, + "type": requestType, + "status": dataSubjectRequestStatusPending, + "requestedAt": time.Now().UTC().UnixMilli(), + "payload": sanitizeDataSubjectPayload(payload), + } + created, err := a.create("data_subject_requests", requestDoc) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to record data subject request") + return + } + + a.recordAuditEvent(r.Context(), claims, "compliance.data_subject."+requestType, "data_subject", auditResultSuccess, map[string]any{ + "requestId": created["id"], + }) + writeJSON(w, http.StatusAccepted, Doc{ + "request": created, + }) +} + +func (a *app) eraseMyDataSubjectRecord(w http.ResponseWriter, r *http.Request) { + claims, ok := claimsFromRequest(r) + if !ok { + writeError(w, http.StatusUnauthorized, "no authenticated user") + return + } + if a.db == nil { + writeError(w, http.StatusServiceUnavailable, "database not configured") + return + } + + var payload struct { + Mode string `json:"mode"` + Reason string `json:"reason"` + } + if err := decodeJSON(r, &payload); err != nil { + writeError(w, http.StatusBadRequest, "invalid request payload") + return + } + + subjectID := strings.TrimSpace(claims.Sub) + if subjectID == "" { + writeError(w, http.StatusBadRequest, "authenticated subject is required") + return + } + + mode := strings.TrimSpace(strings.ToLower(payload.Mode)) + if mode == "" { + mode = "anonymize" + } + if mode != "anonymize" { + writeError(w, http.StatusBadRequest, "mode must be anonymize") + return + } + + tenantID, enforceTenant := entitlementTenantScopeFromRequest(r) + now := time.Now().UTC().UnixMilli() + + userPatch := Doc{ + "email": "deleted+" + subjectID + "@example.invalid", + "name": "Deleted User", + "username": "deleted-" + subjectID, + "preferred_username": "deleted-" + subjectID, + "status": "inactive", + "gdprErasedAt": now, + } + user, err := a.patch("users", subjectID, userPatch) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to anonymize user identity") + return + } + + ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) + defer cancel() + enrollmentCollection, err := a.resolveCollection("enrollments") + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to resolve enrollments collection") + return + } + + enrollmentFilter := Doc{"userId": subjectID} + if enforceTenant { + enrollmentFilter["tenantId"] = tenantID + } + enrollmentResult, err := enrollmentCollection.UpdateMany(ctx, enrollmentFilter, Doc{ + "$set": Doc{ + "userId": "erased:" + subjectID, + "erasedUserId": subjectID, + "gdprErasedAt": now, + "updatedAt": now, + "archivedReason": "data_subject_erasure", + }, + "$unset": Doc{ + "score": "", + "progress": "", + }, + }) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to anonymize educational records") + return + } + + requestDoc := Doc{ + "userId": subjectID, + "tenantId": tenantID, + "type": dataSubjectRequestTypeErasure, + "status": dataSubjectRequestStatusCompleted, + "requestedAt": now, + "completedAt": now, + "mode": mode, + "reason": strings.TrimSpace(payload.Reason), + } + _, _ = a.create("data_subject_requests", requestDoc) + + a.recordAuditEvent(r.Context(), claims, "compliance.data_subject.erase", "data_subject", auditResultSuccess, map[string]any{ + "mode": mode, + "tenantScoped": enforceTenant, + "enrollmentsAnonymized": enrollmentResult.ModifiedCount, + }) + + writeJSON(w, http.StatusOK, Doc{ + "status": "completed", + "userId": user["id"], + "enrollmentsAnonymized": enrollmentResult.ModifiedCount, + "completedAt": now, + }) +} + +func (a *app) getMyConsentReceipt(w http.ResponseWriter, r *http.Request) { + claims, ok := claimsFromRequest(r) + if !ok { + writeError(w, http.StatusUnauthorized, "no authenticated user") + return + } + if a.db == nil { + writeError(w, http.StatusServiceUnavailable, "database not configured") + return + } + + userID := strings.TrimSpace(claims.Sub) + if userID == "" { + writeError(w, http.StatusBadRequest, "authenticated subject is required") + return + } + tenantID, enforceTenant := entitlementTenantScopeFromRequest(r) + filter := Doc{"userId": userID, "revokedAt": Doc{"$exists": false}} + if enforceTenant { + filter["tenantId"] = tenantID + } + + receipts, err := a.list("consent_receipts", filter, Sort{{Key: "decidedAt", Value: -1}, {Key: "updatedAt", Value: -1}}) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to load consent receipt") + return + } + if len(receipts) == 0 { + writeJSON(w, http.StatusOK, Doc{"receipt": nil}) + return + } + writeJSON(w, http.StatusOK, Doc{"receipt": receipts[0]}) +} + +func (a *app) upsertMyConsentReceipt(w http.ResponseWriter, r *http.Request) { + claims, ok := claimsFromRequest(r) + if !ok { + writeError(w, http.StatusUnauthorized, "no authenticated user") + return + } + if a.db == nil { + writeError(w, http.StatusServiceUnavailable, "database not configured") + return + } + + var payload struct { + PolicyVersion string `json:"policyVersion"` + PolicyURL string `json:"policyUrl"` + Analytics bool `json:"analytics"` + Marketing bool `json:"marketing"` + Preferences bool `json:"preferences"` + } + if err := decodeJSON(r, &payload); err != nil { + writeError(w, http.StatusBadRequest, "invalid consent payload") + return + } + + userID := strings.TrimSpace(claims.Sub) + if userID == "" { + writeError(w, http.StatusBadRequest, "authenticated subject is required") + return + } + policyVersion := strings.TrimSpace(payload.PolicyVersion) + if policyVersion == "" { + writeError(w, http.StatusBadRequest, "policyVersion is required") + return + } + + tenantID, enforceTenant := entitlementTenantScopeFromRequest(r) + now := time.Now().UTC().UnixMilli() + filter := Doc{"userId": userID, "revokedAt": Doc{"$exists": false}} + if enforceTenant { + filter["tenantId"] = tenantID + } + + ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) + defer cancel() + collection, err := a.resolveCollection("consent_receipts") + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to resolve consent receipts collection") + return + } + _, _ = collection.UpdateMany(ctx, filter, Doc{"$set": Doc{ + "revokedAt": now, + "revokedReason": "superseded", + "updatedAt": now, + }}) + + doc := Doc{ + "userId": userID, + "tenantId": tenantID, + "policyVersion": policyVersion, + "policyUrl": strings.TrimSpace(payload.PolicyURL), + "categories": Doc{ + "necessary": true, + "analytics": payload.Analytics, + "marketing": payload.Marketing, + "preferences": payload.Preferences, + }, + "decidedAt": now, + } + created, err := a.create("consent_receipts", doc) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to store consent receipt") + return + } + + a.recordAuditEvent(r.Context(), claims, "consent.receipt.upsert", "consent_receipts", auditResultSuccess, map[string]any{ + "policyVersion": policyVersion, + }) + writeJSON(w, http.StatusOK, Doc{"receipt": created}) +} + +func claimsFromRequest(r *http.Request) (*accessClaims, bool) { + if r == nil { + return nil, false + } + claims, _ := r.Context().Value(userClaimsKey).(*accessClaims) + if claims == nil { + return nil, false + } + return claims, true +} + +func sanitizeDataSubjectPayload(payload map[string]any) Doc { + if payload == nil { + return Doc{} + } + out := Doc{} + if reason, ok := payload["reason"].(string); ok { + out["reason"] = strings.TrimSpace(reason) + } + if fields, ok := payload["fields"].(map[string]any); ok { + allowed := Doc{} + for key, raw := range fields { + value, isString := raw.(string) + if !isString { + continue + } + switch strings.TrimSpace(key) { + case "name", "displayName", "email": + allowed[key] = strings.TrimSpace(value) + } + } + if len(allowed) > 0 { + out["fields"] = allowed + } + } + return out +} diff --git a/Backend/cmd/api/config.go b/Backend/cmd/api/config.go new file mode 100644 index 0000000..b9282b6 --- /dev/null +++ b/Backend/cmd/api/config.go @@ -0,0 +1,143 @@ +package main + +import ( + "os" + "strconv" + "strings" + "time" +) + +type config struct { + Port string + WebDir string + AppBaseURL string + MCPResourceURL string + CorsOrigins []string + DatabaseURL string + MinIOEndpoint string + MinIOBucket string + MinIOAccessKey string + MinIOSecretKey string + AssetEncryptionEnabled bool + AuthDisabled bool + KeycloakIssuer string + KeycloakIssuerInternal string + KeycloakAudience string + StripeSecretKey string + StripeAPIBaseURL string + StripeWebhookSecret string + StripeWebhookTolerance time.Duration + StripeAllowedPriceIDs []string + StripeCheckoutSuccessURL string + StripeCheckoutCancelURL string + BillingEventRetentionDays int + EntitlementHistoryRetentionDays int + EducationalRecordRetentionDays int + DefaultTrialDays int + DefaultFreeDays int + KeycloakThemeDeployEnabled bool + NotificationProvider string + SendGridAPIKey string + SendGridFromEmail string + SendGridAPIBaseURL string + ShutdownTimeout time.Duration +} + +func loadConfig() config { + return config{ + Port: getEnv("PORT", "8080"), + WebDir: strings.TrimSpace(getEnv("WEB_DIR", "")), + AppBaseURL: strings.TrimRight(getEnv("APP_BASE_URL", "http://localhost:5173"), "/"), + MCPResourceURL: strings.TrimRight(getEnv("MCP_RESOURCE_URL", "http://localhost:8080/api/mcp"), "/"), + CorsOrigins: parseList(getEnv("CORS_ALLOWED_ORIGINS", "*")), + DatabaseURL: getEnv("DATABASE_URL", "postgres://cms:cms@postgres:5432/cms?sslmode=disable"), + MinIOEndpoint: getEnv("MINIO_ENDPOINT", ""), + MinIOBucket: getEnv("MINIO_BUCKET", ""), + MinIOAccessKey: getEnv("MINIO_ACCESS_KEY", ""), + MinIOSecretKey: getEnv("MINIO_SECRET_KEY", ""), + AssetEncryptionEnabled: getEnvBool("ASSET_ENCRYPTION_ENABLED", false), + AuthDisabled: getEnvBool("AUTH_DISABLED", false), + KeycloakIssuer: getEnv("KEYCLOAK_ISSUER", "http://localhost:8081/realms/cms"), + KeycloakIssuerInternal: getEnv("KEYCLOAK_ISSUER_INTERNAL", "http://keycloak:8080/realms/cms"), + KeycloakAudience: getEnv("KEYCLOAK_AUDIENCE", "cms-backend"), + StripeSecretKey: getEnv("STRIPE_SECRET_KEY", ""), + StripeAPIBaseURL: getEnv("STRIPE_API_BASE_URL", "https://api.stripe.com"), + StripeWebhookSecret: getEnv("STRIPE_WEBHOOK_SECRET", ""), + StripeWebhookTolerance: time.Duration(getEnvInt("STRIPE_WEBHOOK_TOLERANCE_SECONDS", 300)) * time.Second, + StripeAllowedPriceIDs: parseOptionalList(getEnv("STRIPE_ALLOWED_PRICE_IDS", "")), + StripeCheckoutSuccessURL: getEnv("STRIPE_CHECKOUT_SUCCESS_URL", "http://localhost:5173/learn/billing?checkout=success"), + StripeCheckoutCancelURL: getEnv("STRIPE_CHECKOUT_CANCEL_URL", "http://localhost:5173/learn/billing?checkout=cancel"), + BillingEventRetentionDays: getEnvInt("BILLING_EVENT_RETENTION_DAYS", 365), + EntitlementHistoryRetentionDays: getEnvInt("ENTITLEMENT_HISTORY_RETENTION_DAYS", 730), + EducationalRecordRetentionDays: getEnvInt("EDUCATIONAL_RECORD_RETENTION_DAYS", 2555), + DefaultTrialDays: getEnvInt("DEFAULT_TRIAL_DAYS", 14), + DefaultFreeDays: getEnvInt("DEFAULT_FREE_ACCESS_DAYS", 3650), + KeycloakThemeDeployEnabled: getEnvBool("KEYCLOAK_THEME_DEPLOY_ENABLED", true), + NotificationProvider: strings.ToLower(getEnv("NOTIFICATION_PROVIDER", "noop")), + SendGridAPIKey: getEnv("SENDGRID_API_KEY", ""), + SendGridFromEmail: getEnv("SENDGRID_FROM_EMAIL", ""), + SendGridAPIBaseURL: getEnv("SENDGRID_API_BASE_URL", "https://api.sendgrid.com"), + ShutdownTimeout: time.Duration(getEnvInt("SHUTDOWN_TIMEOUT_SECONDS", 10)) * time.Second, + } +} + +func getEnv(key, fallback string) string { + value, ok := os.LookupEnv(key) + if !ok || strings.TrimSpace(value) == "" { + return fallback + } + return value +} + +func getEnvInt(key string, fallback int) int { + raw := strings.TrimSpace(getEnv(key, "")) + if raw == "" { + return fallback + } + value, err := strconv.Atoi(raw) + if err != nil { + return fallback + } + return value +} + +func getEnvBool(key string, fallback bool) bool { + raw := strings.TrimSpace(strings.ToLower(getEnv(key, ""))) + if raw == "" { + return fallback + } + return raw == "1" || raw == "true" || raw == "yes" || raw == "on" +} + +func parseList(raw string) []string { + if strings.TrimSpace(raw) == "" { + return []string{"*"} + } + parts := strings.Split(raw, ",") + out := make([]string, 0, len(parts)) + for _, part := range parts { + item := strings.TrimSpace(part) + if item != "" { + out = append(out, item) + } + } + if len(out) == 0 { + return []string{"*"} + } + return out +} + +func parseOptionalList(raw string) []string { + if strings.TrimSpace(raw) == "" { + return []string{} + } + parts := strings.Split(raw, ",") + out := make([]string, 0, len(parts)) + for _, part := range parts { + item := strings.TrimSpace(part) + if item != "" { + out = append(out, item) + } + } + return out +} diff --git a/Backend/cmd/api/entitlements.go b/Backend/cmd/api/entitlements.go new file mode 100644 index 0000000..267c4c8 --- /dev/null +++ b/Backend/cmd/api/entitlements.go @@ -0,0 +1,571 @@ +package main + +import ( + "errors" + "net/http" + "strings" + "time" + + "github.com/go-chi/chi/v5" +) + +const ( + entitlementStatusActive = "active" + entitlementStatusTrial = "trial" + entitlementStatusGrace = "grace" + entitlementStatusRevoked = "revoked" + entitlementStatusExpired = "expired" + entitlementScopeUser = "user" + entitlementScopeTenant = "tenant" + entitlementScopePlatform = "platform" +) + +var entitlementActiveStatuses = map[string]struct{}{ + entitlementStatusActive: {}, + entitlementStatusTrial: {}, + entitlementStatusGrace: {}, +} + +func (a *app) entitlementsAdminMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + claims, _ := r.Context().Value(userClaimsKey).(*accessClaims) + if claims == nil { + writeError(w, http.StatusUnauthorized, "no authenticated user") + return + } + if !hasRole(claims, "admin") { + writeError(w, http.StatusForbidden, "insufficient role for entitlement management") + return + } + next.ServeHTTP(w, r) + }) +} + +func (a *app) listEntitlements(w http.ResponseWriter, r *http.Request) { + if a.db == nil { + writeError(w, http.StatusServiceUnavailable, "database not configured") + return + } + + filter := Doc{} + if tenantID, enforceTenant := entitlementTenantScopeFromRequest(r); enforceTenant { + filter["tenantId"] = tenantID + } + if userID := strings.TrimSpace(r.URL.Query().Get("userId")); userID != "" { + filter["userId"] = userID + } + if key := strings.TrimSpace(r.URL.Query().Get("key")); key != "" { + filter["key"] = key + } + if status := strings.TrimSpace(strings.ToLower(r.URL.Query().Get("status"))); status != "" { + filter["status"] = status + } + + docs, err := a.list("entitlements", filter, Sort{{Key: "updatedAt", Value: -1}, {Key: "createdAt", Value: -1}}) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to list entitlements") + return + } + + writeJSON(w, http.StatusOK, docs) +} + +func (a *app) createEntitlement(w http.ResponseWriter, r *http.Request) { + if a.db == nil { + writeError(w, http.StatusServiceUnavailable, "database not configured") + return + } + + var payload map[string]any + if err := decodeJSON(r, &payload); err != nil { + writeError(w, http.StatusBadRequest, "invalid payload") + return + } + + doc, err := prepareEntitlementPayload(Doc(payload), false) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + doc = applyEntitlementScopeDefaults(doc, entitlementScopeFromClaims(r)) + + doc, err = a.create("entitlements", doc) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to create entitlement") + return + } + + writeJSON(w, http.StatusCreated, doc) +} + +func (a *app) getEntitlement(w http.ResponseWriter, r *http.Request) { + a.getCollection("entitlements", w, r) +} + +func (a *app) patchEntitlement(w http.ResponseWriter, r *http.Request) { + if a.db == nil { + writeError(w, http.StatusServiceUnavailable, "database not configured") + return + } + + id := strings.TrimSpace(chi.URLParam(r, "id")) + if id == "" { + writeError(w, http.StatusBadRequest, "entitlement id is required") + return + } + + var payload map[string]any + if err := decodeJSON(r, &payload); err != nil { + writeError(w, http.StatusBadRequest, "invalid patch payload") + return + } + + patch, err := prepareEntitlementPayload(Doc(payload), true) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + if tenantID, enforceTenant := entitlementTenantScopeFromRequest(r); enforceTenant { + patch["tenantId"] = tenantID + patch["scope"] = entitlementScopeTenant + } + claims, _ := r.Context().Value(userClaimsKey).(*accessClaims) + if claims != nil { + patch["lastUpdatedByUserId"] = strings.TrimSpace(claims.Sub) + } + + doc, err := a.patch("entitlements", id, patch) + if err != nil { + if errors.Is(err, ErrNotFound) { + writeError(w, http.StatusNotFound, "resource not found") + return + } + writeError(w, http.StatusInternalServerError, "failed to update entitlement") + return + } + + writeJSON(w, http.StatusOK, doc) +} + +func (a *app) deleteEntitlement(w http.ResponseWriter, r *http.Request) { + a.deleteCollection("entitlements", w, r) +} + +func (a *app) revokeEntitlement(w http.ResponseWriter, r *http.Request) { + if a.db == nil { + writeError(w, http.StatusServiceUnavailable, "database not configured") + return + } + + id := strings.TrimSpace(chi.URLParam(r, "id")) + if id == "" { + writeError(w, http.StatusBadRequest, "entitlement id is required") + return + } + + doc, err := a.patch("entitlements", id, Doc{ + "status": entitlementStatusRevoked, + "revokedAt": time.Now().UnixMilli(), + "reason": "manual_revoke", + }) + if err != nil { + if errors.Is(err, ErrNotFound) { + writeError(w, http.StatusNotFound, "resource not found") + return + } + writeError(w, http.StatusInternalServerError, "failed to revoke entitlement") + return + } + + writeJSON(w, http.StatusOK, doc) +} + +func (a *app) checkMyEntitlement(w http.ResponseWriter, r *http.Request) { + claims, _ := r.Context().Value(userClaimsKey).(*accessClaims) + subject, ok := a.learnerSubjectFromContext(r) + if claims == nil || !ok { + writeError(w, http.StatusUnauthorized, "missing learner subject") + return + } + + if a.db == nil { + writeError(w, http.StatusServiceUnavailable, "database not configured") + return + } + + key := strings.TrimSpace(chi.URLParam(r, "key")) + if key == "" { + writeError(w, http.StatusBadRequest, "entitlement key is required") + return + } + + tenantID := strings.TrimSpace(resolveClaimsTenantID(claims)) + allowed, err := a.hasActiveEntitlementScoped(subject, key, time.Now().UTC(), tenantID, tenantID != "") + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to check entitlement") + return + } + + result := Doc{ + "key": key, + "allowed": allowed, + "scope": entitlementScopeFromClaims(r), + "tenantId": tenantID, + } + if !allowed { + result["reason"] = "missing_entitlement" + result["message"] = "Your entitlement is missing or inactive for this resource." + } + writeJSON(w, http.StatusOK, result) +} + +func (a *app) activateMyTrialEntitlement(w http.ResponseWriter, r *http.Request) { + a.activateMyPromotionalEntitlement(w, r, entitlementStatusTrial, "trial") +} + +func (a *app) activateMyFreeEntitlement(w http.ResponseWriter, r *http.Request) { + a.activateMyPromotionalEntitlement(w, r, entitlementStatusActive, "free_account") +} + +func (a *app) activateMyPromotionalEntitlement(w http.ResponseWriter, r *http.Request, status, source string) { + if a.db == nil { + writeError(w, http.StatusServiceUnavailable, "database not configured") + return + } + claims, _ := r.Context().Value(userClaimsKey).(*accessClaims) + subject, ok := a.learnerSubjectFromContext(r) + if claims == nil || !ok { + writeError(w, http.StatusUnauthorized, "missing learner subject") + return + } + key := strings.TrimSpace(chi.URLParam(r, "key")) + if key == "" { + writeError(w, http.StatusBadRequest, "entitlement key is required") + return + } + days := a.cfg.DefaultTrialDays + if source == "free_account" { + days = a.cfg.DefaultFreeDays + } + if days <= 0 { + days = 1 + } + now := time.Now().UTC() + scope := entitlementScopeFromClaims(r) + tenantID := strings.TrimSpace(resolveClaimsTenantID(claims)) + filter := Doc{"userId": subject, "key": key} + if scope == entitlementScopeTenant && tenantID != "" { + filter["tenantId"] = tenantID + } + existing, err := a.list("entitlements", filter, Sort{{Key: "updatedAt", Value: -1}, {Key: "createdAt", Value: -1}}) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to evaluate existing entitlement") + return + } + if len(existing) > 0 && isEntitlementActive(existing[0], now) { + writeJSON(w, http.StatusOK, existing[0]) + return + } + doc, err := prepareEntitlementPayload(Doc{ + "userId": subject, + "key": key, + "status": status, + "source": source, + "startsAt": now.UnixMilli(), + "endsAt": now.Add(time.Duration(days) * 24 * time.Hour).UnixMilli(), + "initiatedByUserId": subject, + "reason": source + "_activation", + }, false) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + doc = applyEntitlementScopeDefaults(doc, scope, tenantID) + if source == "free_account" && len(existing) > 0 { + previousStatus, _ := asString(existing[0]["status"]) + if strings.EqualFold(previousStatus, entitlementStatusTrial) { + doc["convertedFromTrialAt"] = now.UnixMilli() + doc["convertedFromEntitlementId"] = stringValue(existing[0]["id"]) + } + } + created, err := a.create("entitlements", doc) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to activate entitlement") + return + } + writeJSON(w, http.StatusCreated, created) +} + +func prepareEntitlementPayload(payload Doc, partial bool) (Doc, error) { + if payload == nil { + payload = Doc{} + } + + userID, hasUserID, err := getOptionalString(payload, "userId") + if err != nil { + return nil, errors.New("userId must be a string") + } + if !partial && (!hasUserID || userID == "") { + return nil, errors.New("userId is required") + } + if hasUserID { + payload["userId"] = userID + } + + key, hasKey, err := getOptionalString(payload, "key") + if err != nil { + return nil, errors.New("key must be a string") + } + if !partial && (!hasKey || key == "") { + return nil, errors.New("key is required") + } + if hasKey { + payload["key"] = key + } + + status, hasStatus, err := getOptionalString(payload, "status") + if err != nil { + return nil, errors.New("status must be a string") + } + if !partial && !hasStatus { + status = entitlementStatusActive + hasStatus = true + } + if hasStatus { + normalizedStatus := strings.ToLower(strings.TrimSpace(status)) + switch normalizedStatus { + case entitlementStatusActive, entitlementStatusTrial, entitlementStatusGrace, entitlementStatusRevoked, entitlementStatusExpired: + payload["status"] = normalizedStatus + default: + return nil, errors.New("status must be one of: active, trial, grace, revoked, expired") + } + } + + source, hasSource, err := getOptionalString(payload, "source") + if err != nil { + return nil, errors.New("source must be a string") + } + if !partial && !hasSource { + source = "manual_override" + hasSource = true + } + if hasSource { + payload["source"] = strings.ToLower(strings.TrimSpace(source)) + } + + scope, hasScope, err := getOptionalString(payload, "scope") + if err != nil { + return nil, errors.New("scope must be a string") + } + if hasScope { + normalizedScope := strings.ToLower(strings.TrimSpace(scope)) + switch normalizedScope { + case entitlementScopeUser, entitlementScopeTenant, entitlementScopePlatform: + payload["scope"] = normalizedScope + default: + return nil, errors.New("scope must be one of: user, tenant, platform") + } + } + + tenantID, hasTenantID, err := getOptionalString(payload, "tenantId") + if err != nil { + return nil, errors.New("tenantId must be a string") + } + if hasTenantID { + payload["tenantId"] = strings.TrimSpace(tenantID) + } + + platformAccountID, hasPlatformAccountID, err := getOptionalString(payload, "platformAccountId") + if err != nil { + return nil, errors.New("platformAccountId must be a string") + } + if hasPlatformAccountID { + payload["platformAccountId"] = strings.TrimSpace(platformAccountID) + } + + startsAt, hasStartsAt, err := parseOptionalTime(payload["startsAt"]) + if err != nil { + return nil, errors.New("startsAt must be a unix milliseconds number or RFC3339 string") + } + if hasStartsAt { + payload["startsAt"] = startsAt.UnixMilli() + } + + endsAt, hasEndsAt, err := parseOptionalTime(payload["endsAt"]) + if err != nil { + return nil, errors.New("endsAt must be a unix milliseconds number or RFC3339 string") + } + if hasEndsAt { + payload["endsAt"] = endsAt.UnixMilli() + } + + if hasStartsAt && hasEndsAt && endsAt.Before(startsAt) { + return nil, errors.New("endsAt must be greater than or equal to startsAt") + } + + revokedAt, hasRevokedAt, err := parseOptionalTime(payload["revokedAt"]) + if err != nil { + return nil, errors.New("revokedAt must be a unix milliseconds number or RFC3339 string") + } + if hasRevokedAt { + payload["revokedAt"] = revokedAt.UnixMilli() + } + + if metadata, exists := payload["metadata"]; exists && metadata != nil { + value, ok := asMap(metadata) + if !ok { + return nil, errors.New("metadata must be an object") + } + payload["metadata"] = Doc(value) + } + + if statusRaw, has := payload["status"]; has { + statusValue, _ := statusRaw.(string) + if statusValue == entitlementStatusRevoked && !hasRevokedAt { + payload["revokedAt"] = time.Now().UnixMilli() + } + } + + scopeValue, _ := asString(payload["scope"]) + switch strings.TrimSpace(scopeValue) { + case entitlementScopeTenant: + if strings.TrimSpace(anyToString(payload["tenantId"])) == "" { + return nil, errors.New("tenantId is required when scope is tenant") + } + case entitlementScopePlatform: + if strings.TrimSpace(anyToString(payload["platformAccountId"])) == "" { + payload["platformAccountId"] = "platform" + } + } + + return payload, nil +} + +func isEntitlementActive(doc Doc, now time.Time) bool { + status, _ := asString(doc["status"]) + if _, ok := entitlementActiveStatuses[strings.ToLower(strings.TrimSpace(status))]; !ok { + return false + } + + if startsAt, ok := toTime(doc["startsAt"]); ok && now.Before(startsAt.UTC()) { + return false + } + if endsAt, ok := toTime(doc["endsAt"]); ok && now.After(endsAt.UTC()) { + return false + } + if revokedAt, ok := toTime(doc["revokedAt"]); ok && !revokedAt.IsZero() { + return false + } + + return true +} + +func (a *app) hasActiveEntitlement(userID, key string, now time.Time) (bool, error) { + return a.hasActiveEntitlementScoped(userID, key, now, "", false) +} + +func (a *app) hasActiveEntitlementScoped(userID, key string, now time.Time, tenantID string, enforceTenant bool) (bool, error) { + if a.db == nil { + return false, errors.New("database not configured") + } + if strings.TrimSpace(userID) == "" || strings.TrimSpace(key) == "" { + return false, nil + } + + filter := Doc{"userId": strings.TrimSpace(userID), "key": strings.TrimSpace(key)} + if enforceTenant { + filter["tenantId"] = strings.TrimSpace(tenantID) + } + docs, err := a.list("entitlements", filter, Sort{{Key: "updatedAt", Value: -1}, {Key: "createdAt", Value: -1}}) + if err != nil { + return false, err + } + + now = now.UTC() + for _, doc := range docs { + if isEntitlementActive(doc, now) { + return true, nil + } + } + + return false, nil +} + +func (a *app) hasRequiredEntitlement(userID, requiredKey string, enrollment Doc) (bool, error) { + required := strings.TrimSpace(requiredKey) + if required == "" { + return true, nil + } + return a.hasActiveEntitlement(userID, required, time.Now().UTC()) +} + +func (a *app) learnerEntitlementDecision(r *http.Request, userID, requiredKey string) (bool, string, string, error) { + required := strings.TrimSpace(requiredKey) + if required == "" { + return true, "", "", nil + } + tenantID, enforceTenant := entitlementTenantScopeFromRequest(r) + allowed, err := a.hasActiveEntitlementScoped(userID, required, time.Now().UTC(), tenantID, enforceTenant) + if err != nil { + return false, "entitlement_check_failed", "Failed to verify entitlement for this resource.", err + } + if allowed { + return true, "", "", nil + } + return false, "missing_entitlement", "Your entitlement is missing or inactive for this resource.", nil +} + +func entitlementTenantScopeFromRequest(r *http.Request) (string, bool) { + if r == nil { + return "", false + } + tenantCtx, _ := r.Context().Value(tenantContextKey).(tenantContext) + if tenantCtx.BypassScoping && !tenantCtx.RequestedByOps { + return "", false + } + tenantID := strings.TrimSpace(tenantCtx.ID) + if tenantID == "" { + return "", false + } + return tenantID, true +} + +func entitlementScopeFromClaims(r *http.Request) string { + if r == nil { + return entitlementScopeUser + } + claims, _ := r.Context().Value(userClaimsKey).(*accessClaims) + if claims == nil { + return entitlementScopeUser + } + tenantID := strings.TrimSpace(resolveClaimsTenantID(claims)) + if tenantID != "" { + return entitlementScopeTenant + } + return entitlementScopeUser +} + +func applyEntitlementScopeDefaults(doc Doc, scope string, tenantID ...string) Doc { + if doc == nil { + doc = Doc{} + } + selectedScope := strings.TrimSpace(strings.ToLower(scope)) + if selectedScope == "" { + selectedScope = entitlementScopeUser + } + doc["scope"] = selectedScope + tenant := "" + if len(tenantID) > 0 { + tenant = strings.TrimSpace(tenantID[0]) + } + if selectedScope == entitlementScopeTenant { + if tenant == "" { + tenant = strings.TrimSpace(anyToString(doc["tenantId"])) + } + doc["tenantId"] = tenant + } + if selectedScope == entitlementScopePlatform { + doc["platformAccountId"] = "platform" + } + return doc +} diff --git a/Backend/cmd/api/forms.go b/Backend/cmd/api/forms.go new file mode 100644 index 0000000..3d8408d --- /dev/null +++ b/Backend/cmd/api/forms.go @@ -0,0 +1,561 @@ +package main + +import ( + "context" + "errors" + "net/http" + "strconv" + "strings" + "time" + + "github.com/go-chi/chi/v5" +) + +const ( + formStatusDraft = "draft" + formStatusPublished = "published" +) + +var formPublishingPolicyInstance = formPublishingPolicy{} + +type formPublishingPolicy struct{} + +var allowedFormFieldTypes = map[string]struct{}{ + "text": {}, + "textarea": {}, + "email": {}, + "number": {}, + "select": {}, + "checkbox": {}, +} + +func (a *app) formsAdminMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + claims, _ := r.Context().Value(userClaimsKey).(*accessClaims) + if claims == nil { + writeError(w, http.StatusUnauthorized, "no authenticated user") + return + } + if !hasRole(claims, "admin") { + writeError(w, http.StatusForbidden, "insufficient role for form management") + return + } + next.ServeHTTP(w, r) + }) +} + +func (a *app) listForms(w http.ResponseWriter, r *http.Request) { + a.listCollection("forms", w) +} + +func (a *app) createForm(w http.ResponseWriter, r *http.Request) { + var payload formUpsertRequest + if err := decodeJSON(r, &payload); err != nil { + writeError(w, http.StatusBadRequest, "invalid payload") + return + } + + doc, err := prepareFormPayload(payload.Doc(), false, nil) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + + doc, err = a.create("forms", doc) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to create resource") + return + } + writeJSON(w, http.StatusCreated, doc) +} + +func (a *app) getForm(w http.ResponseWriter, r *http.Request) { + a.getCollection("forms", w, r) +} + +func (a *app) patchForm(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + var payload formUpsertRequest + if err := decodeJSON(r, &payload); err != nil { + writeError(w, http.StatusBadRequest, "invalid patch payload") + return + } + + existing, err := a.getByID("forms", id) + 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 + } + + patch, err := prepareFormPayload(payload.Doc(), true, existing) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + + doc, err := a.patch("forms", id, patch) + 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) deleteForm(w http.ResponseWriter, r *http.Request) { + a.deleteCollection("forms", w, r) +} + +func (a *app) listFormSubmissions(w http.ResponseWriter, r *http.Request) { + if a.db == nil { + writeError(w, http.StatusServiceUnavailable, "database not configured") + return + } + + formID := strings.TrimSpace(chi.URLParam(r, "id")) + if formID == "" { + writeError(w, http.StatusBadRequest, "form id is required") + return + } + + docs, err := a.list("form_submissions", Doc{"formId": formID}, Sort{{Key: "submittedAt", Value: -1}, {Key: "updatedAt", Value: -1}}) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to list form submissions") + return + } + + writeJSON(w, http.StatusOK, docs) +} + +func (a *app) getPublicForm(w http.ResponseWriter, r *http.Request) { + if a.db == nil { + writeError(w, http.StatusServiceUnavailable, "database not configured") + return + } + + formID := strings.TrimSpace(chi.URLParam(r, "id")) + if formID == "" { + writeError(w, http.StatusBadRequest, "form id is required") + return + } + + ctx, cancel := context.WithTimeout(r.Context(), 8*time.Second) + defer cancel() + + var form Doc + if err := a.db.Collection("forms").FindOne(ctx, Doc{"id": formID}).Decode(&form); err != nil { + if errors.Is(err, ErrNotFound) { + writeError(w, http.StatusNotFound, "form not found") + return + } + writeError(w, http.StatusInternalServerError, "failed to load form") + return + } + normalizeDocument(form) + + status, _ := asString(form["status"]) + if status != formStatusPublished { + writeError(w, http.StatusNotFound, "form not found") + return + } + + schema, _, err := normalizeFormSchema(form["schema"]) + if err != nil { + writeError(w, http.StatusInternalServerError, "form schema is invalid") + return + } + + writeJSON(w, http.StatusOK, publicFormResponse{ + ID: stringValue(form["id"]), + Name: stringValue(form["name"]), + Description: stringValue(form["description"]), + Schema: schema, + }) +} + +func (a *app) createPublicFormSubmission(w http.ResponseWriter, r *http.Request) { + if a.db == nil { + writeError(w, http.StatusServiceUnavailable, "database not configured") + return + } + + formID := strings.TrimSpace(chi.URLParam(r, "id")) + if formID == "" { + writeError(w, http.StatusBadRequest, "form id is required") + return + } + + ctx, cancel := context.WithTimeout(r.Context(), 8*time.Second) + defer cancel() + + var form Doc + if err := a.db.Collection("forms").FindOne(ctx, Doc{"id": formID}).Decode(&form); err != nil { + if errors.Is(err, ErrNotFound) { + writeError(w, http.StatusNotFound, "form not found") + return + } + writeError(w, http.StatusInternalServerError, "failed to load form") + return + } + normalizeDocument(form) + + status, _ := asString(form["status"]) + if status != formStatusPublished { + writeError(w, http.StatusNotFound, "form not found") + return + } + + schema, _, err := normalizeFormSchema(form["schema"]) + if err != nil { + writeError(w, http.StatusInternalServerError, "form schema is invalid") + return + } + + var payload struct { + Values map[string]any `json:"values"` + } + if err := decodeJSON(r, &payload); err != nil { + writeError(w, http.StatusBadRequest, "invalid payload") + return + } + if payload.Values == nil { + payload.Values = map[string]any{} + } + + normalizedValues, err := validateFormSubmission(schema, payload.Values) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + + submission := Doc{ + "formId": formID, + "values": normalizedValues, + "submittedAt": time.Now().UnixMilli(), + } + submission, err = a.create("form_submissions", submission) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to store form submission") + return + } + + submittedAt := int64(0) + switch value := submission["submittedAt"].(type) { + case int64: + submittedAt = value + case int32: + submittedAt = int64(value) + case int: + submittedAt = int64(value) + case float64: + submittedAt = int64(value) + } + writeJSON(w, http.StatusCreated, publicFormSubmissionCreateResponse{ + ID: stringValue(submission["id"]), + FormID: stringValue(submission["formId"]), + SubmittedAt: submittedAt, + }) +} + +func prepareFormPayload(payload Doc, partial bool, existing Doc) (Doc, error) { + if payload == nil { + payload = Doc{} + } + + name, hasName, err := getOptionalString(payload, "name") + if err != nil { + return nil, errors.New("name must be a string") + } + if !partial && (!hasName || name == "") { + return nil, errors.New("name is required") + } + if hasName { + payload["name"] = name + } + + if err := applyPublishingPolicy(payload, partial, existing, formPublishingPolicyInstance); err != nil { + return nil, err + } + + description, hasDescription, err := getOptionalString(payload, "description") + if err != nil { + return nil, errors.New("description must be a string") + } + if hasDescription { + payload["description"] = description + } + + schema, hasSchema, err := normalizeFormSchema(payload["schema"]) + if err != nil { + return nil, err + } + if hasSchema { + payload["schema"] = schema + } + + return payload, nil +} + +func (formPublishingPolicy) defaultStatus() string { + return formStatusDraft +} + +func (formPublishingPolicy) isAllowedStatus(status string) bool { + return status == formStatusDraft || status == formStatusPublished +} + +func (formPublishingPolicy) invalidStatusMessage() string { + return "status must be one of: draft, published" +} + +func (formPublishingPolicy) validate(payload Doc, existing Doc) error { + status, ok := mergedPublishingString(payload, existing, "status") + if !ok || status != formStatusPublished { + return nil + } + + schemaValues, has := mergedPublishingValue(payload, existing, "schema") + if !has { + return errors.New("schema is required when status is published") + } + + normalized, _, err := normalizeFormSchema(schemaValues) + if err != nil { + return err + } + if len(normalized) == 0 { + return errors.New("schema is required when status is published") + } + + return nil +} + +func normalizeFormSchema(raw any) ([]Doc, bool, error) { + if raw == nil { + return nil, false, nil + } + + var values []any + switch source := raw.(type) { + case []any: + values = source + case List: + values = []any(source) + case []Doc: + values = make([]any, 0, len(source)) + for _, field := range source { + values = append(values, field) + } + case []map[string]any: + values = make([]any, 0, len(source)) + for _, field := range source { + values = append(values, field) + } + default: + return nil, false, errors.New("schema must be an array") + } + + normalized := make([]Doc, 0, len(values)) + for _, item := range values { + fieldMap, ok := item.(map[string]any) + if !ok { + bsonField, docOK := item.(Doc) + if !docOK { + return nil, false, errors.New("schema fields must be objects") + } + fieldMap = map[string]any(bsonField) + } + + nameRaw, ok := fieldMap["name"] + if !ok { + return nil, false, errors.New("schema field name is required") + } + name, ok := nameRaw.(string) + if !ok || strings.TrimSpace(name) == "" { + return nil, false, errors.New("schema field name must be a string") + } + name = strings.TrimSpace(name) + + typeRaw, ok := fieldMap["type"] + if !ok { + return nil, false, errors.New("schema field type is required") + } + fieldType, ok := typeRaw.(string) + if !ok { + return nil, false, errors.New("schema field type must be a string") + } + fieldType = strings.ToLower(strings.TrimSpace(fieldType)) + if _, valid := allowedFormFieldTypes[fieldType]; !valid { + return nil, false, errors.New("schema field type is invalid") + } + + normalizedField := Doc{ + "name": name, + "type": fieldType, + } + + if labelRaw, has := fieldMap["label"]; has { + label, ok := labelRaw.(string) + if !ok { + return nil, false, errors.New("schema field label must be a string") + } + normalizedField["label"] = strings.TrimSpace(label) + } + + if requiredRaw, has := fieldMap["required"]; has { + required, ok := requiredRaw.(bool) + if !ok { + return nil, false, errors.New("schema field required must be a boolean") + } + normalizedField["required"] = required + } else { + normalizedField["required"] = false + } + + if optionsRaw, has := fieldMap["options"]; has { + options, _, err := normalizeStringSlice(optionsRaw) + if err != nil { + return nil, false, errors.New("schema field options must be an array of strings") + } + normalizedField["options"] = options + } + + normalized = append(normalized, normalizedField) + } + + return normalized, true, nil +} + +func validateFormSubmission(schema []Doc, values map[string]any) (Doc, error) { + schemaByName := map[string]Doc{} + for _, field := range schema { + name, _ := field["name"].(string) + schemaByName[name] = field + } + + normalized := Doc{} + for key, value := range values { + field, exists := schemaByName[key] + if !exists { + return nil, errors.New("unknown field: " + key) + } + + fieldType, _ := field["type"].(string) + normalizedValue, err := normalizeSubmissionValue(fieldType, value) + if err != nil { + return nil, errors.New(key + ": " + err.Error()) + } + if fieldType == "select" { + if err := validateSelectSubmissionOption(field, normalizedValue); err != nil { + return nil, errors.New(key + ": " + err.Error()) + } + } + normalized[key] = normalizedValue + } + + for _, field := range schema { + name, _ := field["name"].(string) + required, _ := field["required"].(bool) + if !required { + continue + } + + value, exists := normalized[name] + if !exists || isEmptySubmissionValue(value) { + return nil, errors.New("missing required field: " + name) + } + } + + return normalized, nil +} + +func validateSelectSubmissionOption(field Doc, value any) error { + selected, ok := value.(string) + if !ok { + return errors.New("must be a string") + } + + optionsRaw, hasOptions := field["options"] + if !hasOptions || optionsRaw == nil { + return nil + } + + options, hasNormalizedOptions, err := normalizeStringSlice(optionsRaw) + if err != nil { + return errors.New("select options are invalid") + } + if !hasNormalizedOptions { + return nil + } + + normalizedSelected := strings.ToLower(strings.TrimSpace(selected)) + for _, option := range options { + if strings.ToLower(strings.TrimSpace(option)) == normalizedSelected { + return nil + } + } + + return errors.New("must be one of the configured options") +} + +func normalizeSubmissionValue(fieldType string, value any) (any, error) { + switch fieldType { + case "text", "textarea", "email", "select": + text, ok := value.(string) + if !ok { + return nil, errors.New("must be a string") + } + trimmed := strings.TrimSpace(text) + if fieldType == "email" && trimmed != "" && !strings.Contains(trimmed, "@") { + return nil, errors.New("must be a valid email") + } + return trimmed, nil + case "number": + switch typed := value.(type) { + case float64: + return typed, nil + case float32: + return float64(typed), nil + case int: + return float64(typed), nil + case int64: + return float64(typed), nil + case string: + if strings.TrimSpace(typed) == "" { + return "", nil + } + parsed, err := strconv.ParseFloat(strings.TrimSpace(typed), 64) + if err != nil { + return nil, errors.New("must be a number") + } + return parsed, nil + default: + return nil, errors.New("must be a number") + } + case "checkbox": + boolean, ok := value.(bool) + if !ok { + return nil, errors.New("must be a boolean") + } + return boolean, nil + default: + return nil, errors.New("unsupported field type") + } +} + +func isEmptySubmissionValue(value any) bool { + switch typed := value.(type) { + case string: + return strings.TrimSpace(typed) == "" + default: + return value == nil + } +} diff --git a/Backend/cmd/api/http_helpers.go b/Backend/cmd/api/http_helpers.go new file mode 100644 index 0000000..1ae288f --- /dev/null +++ b/Backend/cmd/api/http_helpers.go @@ -0,0 +1,25 @@ +package main + +import ( + "encoding/json" + "log" + "net/http" +) + +func writeJSON(w http.ResponseWriter, status int, payload any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + if err := json.NewEncoder(w).Encode(payload); err != nil { + log.Printf("failed to encode json response: %v", err) + } +} + +func writeError(w http.ResponseWriter, status int, message string) { + writeJSON(w, status, map[string]string{"error": message}) +} + +func decodeJSON(r *http.Request, out any) error { + decoder := json.NewDecoder(r.Body) + decoder.DisallowUnknownFields() + return decoder.Decode(out) +} diff --git a/Backend/cmd/api/keycloak_theme_deployment.go b/Backend/cmd/api/keycloak_theme_deployment.go new file mode 100644 index 0000000..6c1222a --- /dev/null +++ b/Backend/cmd/api/keycloak_theme_deployment.go @@ -0,0 +1,225 @@ +package main + +import ( + "errors" + "net/http" + "strings" + "time" + + "github.com/go-chi/chi/v5" +) + +const ( + themeDeployConfirmation = "DEPLOY" + themeDeployStatusSuccess = "success" + themeDeployStatusFailed = "failed" +) + +type keycloakThemeDeploymentRequest struct { + Summary string `json:"summary"` + Confirmation string `json:"confirmation"` +} + +func (a *app) deployKeycloakTheme(w http.ResponseWriter, r *http.Request) { + claims, _ := r.Context().Value(userClaimsKey).(*accessClaims) + if !hasPermission(claims, permKeycloakThemesDeploy) { + writeError(w, http.StatusForbidden, "missing permission: keycloak_themes.deploy") + return + } + + if !a.cfg.KeycloakThemeDeployEnabled { + writeError(w, http.StatusServiceUnavailable, "theme deployment is disabled") + return + } + + themeID := strings.TrimSpace(chi.URLParam(r, "id")) + if themeID == "" { + writeError(w, http.StatusBadRequest, "id is required") + return + } + + var payload keycloakThemeDeploymentRequest + if err := decodeJSON(r, &payload); err != nil { + writeError(w, http.StatusBadRequest, "invalid payload") + return + } + if err := validateThemeDeploymentRequest(payload); err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + + theme, err := a.getByID("keycloak_themes", themeID) + if err != nil { + if errors.Is(err, ErrNotFound) { + writeError(w, http.StatusNotFound, "resource not found") + return + } + writeError(w, http.StatusInternalServerError, "failed to load keycloak theme") + return + } + + status, _ := theme["status"].(string) + if strings.TrimSpace(strings.ToLower(status)) != keycloakThemeStatusPublished { + writeError(w, http.StatusBadRequest, "only published themes can be deployed") + return + } + + actor := resolveThemeActor(claims) + deploymentKey := keycloakThemeDeploymentKey(theme) + + current, err := a.getCurrentThemeDeployment(deploymentKey) + if err != nil { + a.recordThemeDeploymentAudit(theme, payload.Summary, actor, themeDeployStatusFailed, nil, "failed to resolve deployment state") + writeError(w, http.StatusInternalServerError, "failed to deploy keycloak theme") + return + } + + rollback := buildThemeDeploymentRollback(current) + if err := a.upsertCurrentThemeDeployment(deploymentKey, theme, actor, rollback); err != nil { + a.recordThemeDeploymentAudit(theme, payload.Summary, actor, themeDeployStatusFailed, rollback, "failed to persist deployment state") + writeError(w, http.StatusInternalServerError, "failed to deploy keycloak theme") + return + } + + a.recordThemeDeploymentAudit(theme, payload.Summary, actor, themeDeployStatusSuccess, rollback, "") + writeJSON(w, http.StatusOK, Doc{ + "status": "deployed", + "themeId": strings.TrimSpace(themeID), + "rollbackThemeId": rollback["themeId"], + "rollbackVersion": rollback["version"], + }) +} + +func validateThemeDeploymentRequest(payload keycloakThemeDeploymentRequest) error { + if strings.TrimSpace(payload.Summary) == "" { + return errors.New("deployment summary is required") + } + if strings.TrimSpace(strings.ToUpper(payload.Confirmation)) != themeDeployConfirmation { + return errors.New("deployment confirmation must be DEPLOY") + } + return nil +} + +func resolveThemeActor(claims *accessClaims) string { + if claims == nil { + return "unknown" + } + if value := strings.TrimSpace(claims.PreferredUsername); value != "" { + return value + } + if value := strings.TrimSpace(claims.Email); value != "" { + return value + } + if value := strings.TrimSpace(claims.Sub); value != "" { + return value + } + return "unknown" +} + +func keycloakThemeDeploymentKey(theme Doc) string { + target, _ := theme["target"].(Doc) + realm, _ := target["realm"].(string) + client, _ := target["client"].(string) + organizationID, _ := target["organizationId"].(string) + themeType, _ := theme["type"].(string) + scope, _ := theme["scope"].(string) + + parts := []string{ + "realm=" + strings.TrimSpace(realm), + "client=" + strings.TrimSpace(client), + "scope=" + strings.TrimSpace(scope), + "organization=" + strings.TrimSpace(organizationID), + "type=" + strings.TrimSpace(themeType), + } + return strings.Join(parts, "|") +} + +func buildThemeDeploymentRollback(current Doc) Doc { + if current == nil { + return Doc{} + } + rollback := Doc{} + if themeID, ok := current["themeId"].(string); ok && strings.TrimSpace(themeID) != "" { + rollback["themeId"] = strings.TrimSpace(themeID) + } + if version, ok := current["version"].(string); ok && strings.TrimSpace(version) != "" { + rollback["version"] = strings.TrimSpace(version) + } + if len(rollback) == 0 { + return Doc{} + } + return rollback +} + +func (a *app) getCurrentThemeDeployment(deploymentKey string) (Doc, error) { + entries, err := a.list("keycloak_theme_deployments_current", Doc{"deploymentKey": deploymentKey}, nil) + if err != nil { + return nil, err + } + if len(entries) == 0 { + return nil, nil + } + return entries[0], nil +} + +func (a *app) upsertCurrentThemeDeployment(deploymentKey string, theme Doc, actor string, rollback Doc) error { + target, _ := theme["target"].(Doc) + now := time.Now().UnixMilli() + doc := Doc{ + "deploymentKey": deploymentKey, + "themeId": strings.TrimSpace(anyToString(theme["id"])), + "version": strings.TrimSpace(anyToString(theme["version"])), + "scope": strings.TrimSpace(anyToString(theme["scope"])), + "type": strings.TrimSpace(anyToString(theme["type"])), + "realm": strings.TrimSpace(anyToString(target["realm"])), + "client": strings.TrimSpace(anyToString(target["client"])), + "organizationId": strings.TrimSpace(anyToString(target["organizationId"])), + "deployedBy": strings.TrimSpace(actor), + "deployedAt": now, + "rollback": rollback, + } + + current, err := a.getCurrentThemeDeployment(deploymentKey) + if err != nil { + return err + } + if current == nil { + _, err = a.create("keycloak_theme_deployments_current", doc) + return err + } + _, err = a.patch("keycloak_theme_deployments_current", strings.TrimSpace(anyToString(current["id"])), doc) + return err +} + +func (a *app) recordThemeDeploymentAudit(theme Doc, summary, actor, status string, rollback Doc, errorMessage string) { + audit := buildThemeDeploymentAuditDoc(theme, summary, actor, status, rollback, errorMessage) + _, _ = a.create("keycloak_theme_deployments_audit", audit) +} + +func buildThemeDeploymentAuditDoc(theme Doc, summary, actor, status string, rollback Doc, errorMessage string) Doc { + audit := Doc{ + "themeId": strings.TrimSpace(anyToString(theme["id"])), + "themeSlug": strings.TrimSpace(anyToString(theme["slug"])), + "version": strings.TrimSpace(anyToString(theme["version"])), + "scope": strings.TrimSpace(anyToString(theme["scope"])), + "type": strings.TrimSpace(anyToString(theme["type"])), + "status": strings.TrimSpace(status), + "summary": strings.TrimSpace(summary), + "actor": strings.TrimSpace(actor), + "rollback": rollback, + } + if errorMessage != "" { + audit["errorMessage"] = strings.TrimSpace(errorMessage) + } + return audit +} + +func anyToString(value any) string { + if value == nil { + return "" + } + if raw, ok := value.(string); ok { + return raw + } + return "" +} diff --git a/Backend/cmd/api/keycloak_theme_form_schema.go b/Backend/cmd/api/keycloak_theme_form_schema.go new file mode 100644 index 0000000..223871c --- /dev/null +++ b/Backend/cmd/api/keycloak_theme_form_schema.go @@ -0,0 +1,274 @@ +package main + +import ( + "errors" + "regexp" + "strconv" + "strings" +) + +type keycloakThemeField struct { + Name string + Type string + Required bool + Options []string + Min float64 + Max float64 +} + +var keycloakThemeColorPattern = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`) + +var keycloakThemeFields = []keycloakThemeField{ + {Name: "logoAssetId", Type: "image"}, + {Name: "faviconAssetId", Type: "image"}, + {Name: "logoAssetMimeType", Type: "text"}, + {Name: "faviconAssetMimeType", Type: "text"}, + {Name: "primaryColor", Type: "color", Required: true}, + {Name: "secondaryColor", Type: "color"}, + {Name: "backgroundColor", Type: "color"}, + {Name: "textColor", Type: "color"}, + {Name: "buttonRadius", Type: "number", Min: 0, Max: 32}, + {Name: "fontFamily", Type: "select", Options: []string{"Inter", "System UI", "Roboto", "Open Sans", "Lato", "Montserrat"}}, + {Name: "loginHeading", Type: "text"}, + {Name: "loginSubheading", Type: "textarea"}, + {Name: "loginHelpText", Type: "textarea"}, + {Name: "registrationHeading", Type: "text"}, + {Name: "forgotPasswordHelpText", Type: "textarea"}, + {Name: "accountConsoleHeading", Type: "text"}, + {Name: "emailTemplateFooter", Type: "textarea"}, + {Name: "supportUrl", Type: "url"}, + {Name: "privacyUrl", Type: "url"}, + {Name: "termsUrl", Type: "url"}, + {Name: "footerText", Type: "textarea"}, + {Name: "securityText", Type: "textarea"}, +} + +var keycloakThemeFieldNames = func() map[string]struct{} { + fields := map[string]struct{}{} + for _, field := range keycloakThemeFields { + fields[field.Name] = struct{}{} + } + return fields +}() + +var keycloakThemeAllowedSystemMetadata = map[string]struct{}{ + "updatedBy": {}, + "updatedAt": {}, + "createdBy": {}, + "createdAt": {}, + "platformApproval": {}, + "exportRequested": {}, +} + +var keycloakThemeDefaultFormValues = Doc{ + "primaryColor": "#4f46e5", + "backgroundColor": "#ffffff", + "textColor": "#0f172a", + "fontFamily": "Inter", + "buttonRadius": 8, + "loginHeading": "Welcome back", + "registrationHeading": "Create your account", + "footerText": "Powered by CMS Platform", + "securityText": "Never share your password with anyone.", +} + +func keycloakThemeFormSchemaSections() []Doc { + return []Doc{ + { + "id": "brand", + "title": "Brand", + "fields": []Doc{ + {"type": "image", "name": "logoAssetId", "label": "Logo", "required": false}, + {"type": "image", "name": "faviconAssetId", "label": "Favicon", "required": false}, + {"type": "text", "name": "logoAssetMimeType", "label": "Logo MIME type", "required": false}, + {"type": "text", "name": "faviconAssetMimeType", "label": "Favicon MIME type", "required": false}, + {"type": "color", "name": "primaryColor", "label": "Primary colour", "required": true}, + {"type": "color", "name": "secondaryColor", "label": "Secondary colour", "required": false}, + {"type": "color", "name": "backgroundColor", "label": "Background colour", "required": false}, + {"type": "color", "name": "textColor", "label": "Text colour", "required": false}, + {"type": "select", "name": "fontFamily", "label": "Font family", "required": false, "options": []string{"Inter", "System UI", "Roboto", "Open Sans", "Lato", "Montserrat"}}, + {"type": "number", "name": "buttonRadius", "label": "Button radius", "required": false}, + }, + }, + { + "id": "authCopy", + "title": "Authentication copy", + "fields": []Doc{ + {"type": "text", "name": "loginHeading", "label": "Login heading", "required": false}, + {"type": "textarea", "name": "loginSubheading", "label": "Login subheading", "required": false}, + {"type": "textarea", "name": "loginHelpText", "label": "Login help text", "required": false}, + {"type": "text", "name": "registrationHeading", "label": "Registration heading", "required": false}, + {"type": "textarea", "name": "forgotPasswordHelpText", "label": "Forgot password help", "required": false}, + }, + }, + { + "id": "accountAndEmail", + "title": "Account and email templates", + "fields": []Doc{ + {"type": "text", "name": "accountConsoleHeading", "label": "Account console heading", "required": false}, + {"type": "textarea", "name": "emailTemplateFooter", "label": "Email footer text", "required": false}, + }, + }, + { + "id": "linksAndLegal", + "title": "Links and legal", + "fields": []Doc{ + {"type": "url", "name": "supportUrl", "label": "Support URL", "required": false}, + {"type": "url", "name": "privacyUrl", "label": "Privacy URL", "required": false}, + {"type": "url", "name": "termsUrl", "label": "Terms URL", "required": false}, + {"type": "textarea", "name": "footerText", "label": "Footer text", "required": false}, + {"type": "textarea", "name": "securityText", "label": "Security text", "required": false}, + }, + }, + } +} + +func ensureThemeMetadataDefaults(doc Doc) { + metadata := resolveThemeMetadata(nil, doc) + if metadata == nil { + metadata = Doc{} + } + for key, value := range keycloakThemeDefaultFormValues { + if _, exists := metadata[key]; !exists { + metadata[key] = value + } + } + doc["metadata"] = metadata +} + +func validateKeycloakThemeFormValues(metadata Doc) error { + if metadata == nil { + metadata = Doc{} + } + + fieldMap := map[string]keycloakThemeField{} + for _, field := range keycloakThemeFields { + fieldMap[field.Name] = field + } + + for key, value := range metadata { + field, ok := fieldMap[key] + if !ok { + if _, allowed := keycloakThemeAllowedSystemMetadata[key]; allowed { + continue + } + return errors.New("theme schema validation failed: unknown field " + key) + } + if err := validateThemeFieldValue(field, value); err != nil { + return err + } + } + + for _, field := range keycloakThemeFields { + if !field.Required { + continue + } + value, exists := metadata[field.Name] + if !exists || isEmptyThemeValue(value) { + return errors.New("theme schema validation failed: required field " + field.Name + " is missing") + } + } + + return nil +} + +func validateThemeFieldValue(field keycloakThemeField, value any) error { + if isEmptyThemeValue(value) { + if field.Required { + return errors.New("theme schema validation failed: required field " + field.Name + " is missing") + } + return nil + } + + switch field.Type { + case "text", "textarea", "image": + str, ok := value.(string) + if !ok { + return errors.New("theme schema validation failed: field " + field.Name + " must be a string") + } + trimmed := strings.TrimSpace(str) + if field.Type == "image" && strings.Contains(trimmed, " ") { + return errors.New("theme schema validation failed: image field " + field.Name + " must not contain spaces") + } + case "color": + str, ok := value.(string) + if !ok || !keycloakThemeColorPattern.MatchString(strings.TrimSpace(str)) { + return errors.New("theme schema validation failed: field " + field.Name + " must be a hex color") + } + case "url": + str, ok := value.(string) + if !ok { + return errors.New("theme schema validation failed: field " + field.Name + " must be a string") + } + if strings.TrimSpace(str) != "" { + if err := validateThemeURL(str); err != nil { + return errors.New("theme schema validation failed: field " + field.Name + " must be a valid URL") + } + } + case "number": + number, ok := normalizeThemeNumber(value) + if !ok { + return errors.New("theme schema validation failed: field " + field.Name + " must be a number") + } + if field.Min != 0 || field.Max != 0 { + if number < field.Min || number > field.Max { + return errors.New("theme schema validation failed: field " + field.Name + " is out of range") + } + } + case "select": + str, ok := value.(string) + if !ok { + return errors.New("theme schema validation failed: field " + field.Name + " must be a string") + } + if len(field.Options) == 0 { + return nil + } + trimmed := strings.TrimSpace(str) + for _, option := range field.Options { + if option == trimmed { + return nil + } + } + return errors.New("theme schema validation failed: field " + field.Name + " has an unsupported value") + } + + return nil +} + +func normalizeThemeNumber(value any) (float64, bool) { + switch v := value.(type) { + case float64: + return v, true + case float32: + return float64(v), true + case int: + return float64(v), true + case int32: + return float64(v), true + case int64: + return float64(v), true + case string: + parsed, err := strconv.ParseFloat(strings.TrimSpace(v), 64) + if err != nil { + return 0, false + } + return parsed, true + default: + return 0, false + } +} + +func isEmptyThemeValue(value any) bool { + if value == nil { + return true + } + if str, ok := value.(string); ok { + return strings.TrimSpace(str) == "" + } + return false +} + +func isKeycloakThemeSchemaField(name string) bool { + _, exists := keycloakThemeFieldNames[name] + return exists +} diff --git a/Backend/cmd/api/keycloak_theme_policy.go b/Backend/cmd/api/keycloak_theme_policy.go new file mode 100644 index 0000000..d597294 --- /dev/null +++ b/Backend/cmd/api/keycloak_theme_policy.go @@ -0,0 +1,286 @@ +package main + +import ( + "errors" + "net/url" + "strconv" + "strings" +) + +var allowedThemeFontFamilies = map[string]struct{}{ + "inter": {}, + "system ui": {}, + "roboto": {}, + "open sans": {}, + "lato": {}, + "montserrat": {}, +} + +var allowedThemeAssetMimeTypes = map[string]struct{}{ + "image/png": {}, + "image/jpeg": {}, + "image/webp": {}, + "image/svg+xml": {}, + "image/x-icon": {}, + "image/vnd.microsoft.icon": {}, +} + +func validateKeycloakThemePolicy(existing, patch Doc, claims *accessClaims) error { + scope := resolveThemeString(existing, patch, "scope", keycloakThemeScopePlatform) + status := resolveThemeString(existing, patch, "status", keycloakThemeStatusDraft) + metadata := resolveThemeMetadata(existing, patch) + + if hasUnsafeThemeValue(metadata) { + return errors.New("theme policy violation: unsafe html or javascript content detected") + } + + if err := validateThemeURLFields(metadata); err != nil { + return err + } + + if err := validateThemeFontAndAssetPolicy(metadata); err != nil { + return err + } + + requiresPublishLevelChecks := status == keycloakThemeStatusPublished || isThemeExportRequested(existing, patch, metadata) + if !requiresPublishLevelChecks { + return nil + } + + if err := validateThemeLegalAndSecurityRequirements(metadata); err != nil { + return err + } + + if scope == keycloakThemeScopeOrganization { + if err := validateOrganizationThemePublicationApproval(claims, metadata); err != nil { + return err + } + } + + return nil +} + +func resolveThemeString(existing, patch Doc, key, fallback string) string { + if value, ok := patch[key].(string); ok && strings.TrimSpace(value) != "" { + return strings.TrimSpace(strings.ToLower(value)) + } + if value, ok := existing[key].(string); ok && strings.TrimSpace(value) != "" { + return strings.TrimSpace(strings.ToLower(value)) + } + return fallback +} + +func resolveThemeMetadata(existing, patch Doc) Doc { + if value, ok := patch["metadata"]; ok { + metadata, _ := normalizeThemeMetadata(value) + if metadata != nil { + return metadata + } + } + if value, ok := existing["metadata"]; ok { + metadata, _ := normalizeThemeMetadata(value) + if metadata != nil { + return metadata + } + } + return Doc{} +} + +func hasUnsafeThemeValue(value any) bool { + switch v := value.(type) { + case string: + normalized := strings.ToLower(strings.TrimSpace(v)) + unsafeTokens := []string{" 0 { + if err := decodeJSON(r, &payload); err != nil { + writeError(w, http.StatusBadRequest, "invalid payload") + return + } + } + + merged := mergeThemeMetadata(existing, payload.Metadata) + preview := buildThemePreview(existing, merged) + writeJSON(w, http.StatusOK, preview) +} + +func (a *app) exportKeycloakTheme(w http.ResponseWriter, r *http.Request) { + claims, _ := r.Context().Value(userClaimsKey).(*accessClaims) + if !hasPermission(claims, permKeycloakThemesPreview) { + writeError(w, http.StatusForbidden, "missing permission: keycloak_themes.preview") + return + } + + themeID := strings.TrimSpace(chi.URLParam(r, "id")) + if themeID == "" { + writeError(w, http.StatusBadRequest, "id is required") + return + } + + existing, err := a.getByID("keycloak_themes", themeID) + if err != nil { + writeError(w, http.StatusNotFound, "resource not found") + return + } + + var payload struct { + Metadata map[string]any `json:"metadata"` + } + if r.ContentLength > 0 { + if err := decodeJSON(r, &payload); err != nil { + writeError(w, http.StatusBadRequest, "invalid payload") + return + } + } + + merged := mergeThemeMetadata(existing, payload.Metadata) + if err := validateKeycloakThemeFormValues(merged); err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + + policyPatch := Doc{ + "metadata": merged, + } + policyPatch["exportRequested"] = true + merged["exportRequested"] = true + if err := validateKeycloakThemePolicy(existing, policyPatch, claims); err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + + exportArtifact := generateThemeExportArtifact(existing, merged) + created, err := a.create("keycloak_theme_exports", exportArtifact) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to store theme export artifact") + return + } + + writeJSON(w, http.StatusCreated, created) +} + +func mergeThemeMetadata(existing Doc, override map[string]any) Doc { + metadata := resolveThemeMetadata(existing, nil) + if metadata == nil { + metadata = Doc{} + } + if override == nil { + return sanitizeThemeMetadataForPreviewExport(metadata) + } + for key, value := range override { + metadata[key] = value + } + return sanitizeThemeMetadataForPreviewExport(metadata) +} + +func sanitizeThemeMetadataForPreviewExport(metadata Doc) Doc { + clean := Doc{} + for key, value := range metadata { + trimmed := strings.TrimSpace(key) + if !isKeycloakThemeSchemaField(trimmed) { + continue + } + if isSensitiveThemeField(trimmed) { + continue + } + if hasUnsafeThemeValue(value) { + continue + } + clean[trimmed] = value + } + return clean +} + +func buildThemePreview(themeDoc, metadata Doc) Doc { + typeValue, _ := themeDoc["type"].(string) + if strings.TrimSpace(typeValue) == "" { + typeValue = keycloakThemeTypeLogin + } + + return Doc{ + "themeType": typeValue, + "login": Doc{ + "title": firstThemeString(metadata, "loginHeading", "registrationHeading", "Welcome back"), + "subtitle": firstThemeString(metadata, "loginSubheading", "loginHelpText", "Sign in with your organization account."), + "buttonLabel": "Sign in", + "style": previewStyle(metadata), + }, + "account": Doc{ + "title": firstThemeString(metadata, "accountConsoleHeading", "loginHeading", "Account settings"), + "subtitle": firstThemeString(metadata, "securityText", "footerText", "Manage your account securely."), + "buttonLabel": "Save changes", + "style": previewStyle(metadata), + }, + } +} + +func previewStyle(metadata Doc) Doc { + return Doc{ + "primaryColor": firstThemeString(metadata, "primaryColor", "#4f46e5"), + "backgroundColor": firstThemeString(metadata, "backgroundColor", "#ffffff"), + "textColor": firstThemeString(metadata, "textColor", "#0f172a"), + "fontFamily": firstThemeString(metadata, "fontFamily", "Inter"), + "buttonRadius": firstThemeNumber(metadata, "buttonRadius", 8), + "footerText": firstThemeString(metadata, "footerText", "Powered by CMS Platform"), + "securityText": firstThemeString(metadata, "securityText", "Never share your password with anyone."), + } +} + +func firstThemeString(metadata Doc, keys ...string) string { + for idx, key := range keys { + if idx == len(keys)-1 { + return strings.TrimSpace(key) + } + if value, ok := metadata[key].(string); ok && strings.TrimSpace(value) != "" { + return strings.TrimSpace(value) + } + } + return "" +} + +func firstThemeNumber(metadata Doc, key string, fallback float64) float64 { + if value, ok := metadata[key]; ok { + if parsed, valid := normalizeThemeNumber(value); valid { + return parsed + } + } + return fallback +} + +func generateThemeExportArtifact(themeDoc, metadata Doc) Doc { + slug, _ := themeDoc["slug"].(string) + version, _ := themeDoc["version"].(string) + themeType, _ := themeDoc["type"].(string) + themeID, _ := themeDoc["id"].(string) + + baseDir := fmt.Sprintf("keycloak-theme/themes/%s-%s", slug, version) + css := generateThemeCSS(metadata) + themeProperties := fmt.Sprintf("parent=base\nstyles=css/theme.css\n") + + files := []Doc{ + {"path": fmt.Sprintf("%s/login/theme.properties", baseDir), "contentType": "text/plain", "content": themeProperties}, + {"path": fmt.Sprintf("%s/login/resources/css/theme.css", baseDir), "contentType": "text/css", "content": css}, + {"path": fmt.Sprintf("%s/account/theme.properties", baseDir), "contentType": "text/plain", "content": themeProperties}, + {"path": fmt.Sprintf("%s/account/resources/css/theme.css", baseDir), "contentType": "text/css", "content": css}, + } + + if themeType == keycloakThemeTypeEmail { + files = append(files, Doc{"path": fmt.Sprintf("%s/email/theme.properties", baseDir), "contentType": "text/plain", "content": "parent=base\n"}) + } + + if asset := firstThemeString(metadata, "logoAssetId"); asset != "" { + files = append(files, Doc{"path": fmt.Sprintf("%s/login/resources/img/logo.ref", baseDir), "contentType": "text/plain", "content": asset}) + } + if asset := firstThemeString(metadata, "faviconAssetId"); asset != "" { + files = append(files, Doc{"path": fmt.Sprintf("%s/login/resources/img/favicon.ref", baseDir), "contentType": "text/plain", "content": asset}) + } + + checksum := checksumThemeFiles(files) + + return Doc{ + "themeId": themeID, + "themeSlug": slug, + "version": version, + "checksum": checksum, + "format": "keycloak-theme-directory", + "baseDir": baseDir, + "fileCount": len(files), + "files": files, + "previewOnly": false, + } +} + +func checksumThemeFiles(files []Doc) string { + lines := make([]string, 0, len(files)) + for _, file := range files { + path, _ := file["path"].(string) + content, _ := file["content"].(string) + lines = append(lines, path+"\n"+content) + } + sort.Strings(lines) + + hasher := sha256.New() + for _, line := range lines { + _, _ = hasher.Write([]byte(line)) + } + return hex.EncodeToString(hasher.Sum(nil)) +} + +func generateThemeCSS(metadata Doc) string { + primary := firstThemeString(metadata, "primaryColor", "#4f46e5") + background := firstThemeString(metadata, "backgroundColor", "#ffffff") + text := firstThemeString(metadata, "textColor", "#0f172a") + radius := firstThemeNumber(metadata, "buttonRadius", 8) + font := firstThemeString(metadata, "fontFamily", "Inter") + + return fmt.Sprintf(":root {\n --kc-primary: %s;\n --kc-background: %s;\n --kc-text: %s;\n --kc-radius: %.0fpx;\n --kc-font-family: '%s';\n}\n", primary, background, text, radius, font) +} diff --git a/Backend/cmd/api/keycloak_themes.go b/Backend/cmd/api/keycloak_themes.go new file mode 100644 index 0000000..e21c184 --- /dev/null +++ b/Backend/cmd/api/keycloak_themes.go @@ -0,0 +1,554 @@ +package main + +import ( + "errors" + "net/http" + "strings" + "time" + + "github.com/go-chi/chi/v5" +) + +const ( + keycloakThemeScopePlatform = "platform" + keycloakThemeScopeOrganization = "organization" + + keycloakThemeStatusDraft = "draft" + keycloakThemeStatusPublished = "published" + keycloakThemeStatusArchived = "archived" + + keycloakThemeTypeLogin = "login" + keycloakThemeTypeAccount = "account" + keycloakThemeTypeAdmin = "admin" + keycloakThemeTypeEmail = "email" +) + +func (a *app) keycloakThemeReadMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + claims, _ := r.Context().Value(userClaimsKey).(*accessClaims) + if claims == nil { + writeError(w, http.StatusUnauthorized, "no authenticated user") + return + } + if !hasPermission(claims, permKeycloakThemesRead) { + writeError(w, http.StatusForbidden, "missing permission: keycloak_themes.read") + return + } + next.ServeHTTP(w, r) + }) +} + +func (a *app) listPermissionRegistry(w http.ResponseWriter, r *http.Request) { + writeJSON(w, http.StatusOK, map[string]any{ + "permissions": keycloakThemePermissionRegistry, + }) +} + +func (a *app) listKeycloakThemes(w http.ResponseWriter, r *http.Request) { + claims, _ := r.Context().Value(userClaimsKey).(*accessClaims) + + docs, err := a.list("keycloak_themes", Doc{}, Sort{{Key: "updatedAt", Value: -1}}) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to list keycloak themes") + return + } + + result := make([]Doc, 0, len(docs)) + for _, doc := range docs { + result = append(result, sanitizeThemeDocument(doc, claims)) + } + + writeJSON(w, http.StatusOK, result) +} + +func (a *app) getKeycloakThemeFormSchema(w http.ResponseWriter, r *http.Request) { + writeJSON(w, http.StatusOK, Doc{ + "sections": keycloakThemeFormSchemaSections(), + }) +} + +func (a *app) createKeycloakTheme(w http.ResponseWriter, r *http.Request) { + claims, _ := r.Context().Value(userClaimsKey).(*accessClaims) + + var payload map[string]any + if err := decodeJSON(r, &payload); err != nil { + writeError(w, http.StatusBadRequest, "invalid payload") + return + } + + doc, err := prepareKeycloakThemePayload(Doc(payload), false) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + + ensureThemeMetadataDefaults(doc) + stampThemeEditorMetadata(doc, nil, claims) + if err := validateKeycloakThemeFormValues(resolveThemeMetadata(nil, doc)); err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + + if err := validateKeycloakThemePolicy(nil, doc, claims); err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + + if err := authorizeKeycloakThemeCreate(claims, doc); err != nil { + writeError(w, http.StatusForbidden, err.Error()) + return + } + + created, err := a.create("keycloak_themes", doc) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to create keycloak theme") + return + } + + writeJSON(w, http.StatusCreated, sanitizeThemeDocument(created, claims)) +} + +func (a *app) getKeycloakTheme(w http.ResponseWriter, r *http.Request) { + claims, _ := r.Context().Value(userClaimsKey).(*accessClaims) + + id := strings.TrimSpace(chi.URLParam(r, "id")) + if id == "" { + writeError(w, http.StatusBadRequest, "id is required") + return + } + + doc, err := a.getByID("keycloak_themes", id) + if err != nil { + if errors.Is(err, ErrNotFound) { + writeError(w, http.StatusNotFound, "resource not found") + return + } + writeError(w, http.StatusInternalServerError, "failed to load keycloak theme") + return + } + + writeJSON(w, http.StatusOK, sanitizeThemeDocument(doc, claims)) +} + +func (a *app) patchKeycloakTheme(w http.ResponseWriter, r *http.Request) { + claims, _ := r.Context().Value(userClaimsKey).(*accessClaims) + + id := strings.TrimSpace(chi.URLParam(r, "id")) + if id == "" { + writeError(w, http.StatusBadRequest, "id is required") + return + } + + existing, err := a.getByID("keycloak_themes", id) + if err != nil { + if errors.Is(err, ErrNotFound) { + writeError(w, http.StatusNotFound, "resource not found") + return + } + writeError(w, http.StatusInternalServerError, "failed to load keycloak theme") + return + } + + var patch map[string]any + if err := decodeJSON(r, &patch); err != nil { + writeError(w, http.StatusBadRequest, "invalid patch payload") + return + } + + doc, err := prepareKeycloakThemePayload(Doc(patch), true) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + + stampThemeEditorMetadata(doc, existing, claims) + if err := validateKeycloakThemeFormValues(resolveThemeMetadata(existing, doc)); err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + + if err := validateKeycloakThemePolicy(existing, doc, claims); err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + + if err := authorizeKeycloakThemePatch(claims, existing, doc); err != nil { + writeError(w, http.StatusForbidden, err.Error()) + return + } + + updated, err := a.patch("keycloak_themes", id, doc) + if err != nil { + if errors.Is(err, ErrNotFound) { + writeError(w, http.StatusNotFound, "resource not found") + return + } + writeError(w, http.StatusInternalServerError, "failed to update keycloak theme") + return + } + + writeJSON(w, http.StatusOK, sanitizeThemeDocument(updated, claims)) +} + +func (a *app) deleteKeycloakTheme(w http.ResponseWriter, r *http.Request) { + claims, _ := r.Context().Value(userClaimsKey).(*accessClaims) + + id := strings.TrimSpace(chi.URLParam(r, "id")) + if id == "" { + writeError(w, http.StatusBadRequest, "id is required") + return + } + + existing, err := a.getByID("keycloak_themes", id) + if err != nil { + if errors.Is(err, ErrNotFound) { + writeError(w, http.StatusNotFound, "resource not found") + return + } + writeError(w, http.StatusInternalServerError, "failed to load keycloak theme") + return + } + + if err := authorizeKeycloakThemeDelete(claims, existing); err != nil { + writeError(w, http.StatusForbidden, err.Error()) + return + } + + if err := a.deleteByID("keycloak_themes", id); err != nil { + if errors.Is(err, ErrNotFound) { + writeError(w, http.StatusNotFound, "resource not found") + return + } + writeError(w, http.StatusInternalServerError, "failed to delete keycloak theme") + return + } + + w.WriteHeader(http.StatusNoContent) +} + +func prepareKeycloakThemePayload(payload Doc, partial bool) (Doc, error) { + doc := Doc{} + + if !partial { + if _, ok := payload["status"]; !ok { + doc["status"] = keycloakThemeStatusDraft + } + } + + for key, value := range payload { + switch key { + case "name", "slug", "version": + trimmed, ok := value.(string) + if !ok || strings.TrimSpace(trimmed) == "" { + return nil, errors.New(key + " is required") + } + doc[key] = strings.TrimSpace(trimmed) + case "status": + status, ok := value.(string) + if !ok { + return nil, errors.New("status must be a string") + } + status = strings.TrimSpace(strings.ToLower(status)) + if status != keycloakThemeStatusDraft && status != keycloakThemeStatusPublished && status != keycloakThemeStatusArchived { + return nil, errors.New("status must be one of: draft, published, archived") + } + doc[key] = status + case "scope": + scope, ok := value.(string) + if !ok { + return nil, errors.New("scope must be a string") + } + scope = strings.TrimSpace(strings.ToLower(scope)) + if scope != keycloakThemeScopePlatform && scope != keycloakThemeScopeOrganization { + return nil, errors.New("scope must be one of: platform, organization") + } + doc[key] = scope + case "type": + typeValue, ok := value.(string) + if !ok { + return nil, errors.New("type must be a string") + } + typeValue = strings.TrimSpace(strings.ToLower(typeValue)) + if typeValue != keycloakThemeTypeLogin && typeValue != keycloakThemeTypeAccount && typeValue != keycloakThemeTypeAdmin && typeValue != keycloakThemeTypeEmail { + return nil, errors.New("type must be one of: login, account, admin, email") + } + doc[key] = typeValue + case "target": + target, ok := normalizeThemeTarget(value) + if !ok { + return nil, errors.New("target must include a realm and optional client") + } + doc[key] = target + case "metadata": + metadata, ok := normalizeThemeMetadata(value) + if !ok { + return nil, errors.New("metadata must be an object") + } + if hasSensitiveThemeMetadata(metadata) { + return nil, errors.New("metadata contains restricted keys") + } + doc[key] = metadata + } + } + + if !partial { + required := []string{"name", "slug", "scope", "type", "version", "target"} + for _, key := range required { + if _, ok := doc[key]; !ok { + return nil, errors.New(key + " is required") + } + } + } + + if scope, ok := doc["scope"].(string); ok && scope == keycloakThemeScopeOrganization { + target, _ := doc["target"].(Doc) + orgID, _ := target["organizationId"].(string) + if strings.TrimSpace(orgID) == "" { + return nil, errors.New("target.organizationId is required for organization scope") + } + } + + return doc, nil +} + +func normalizeThemeTarget(value any) (Doc, bool) { + targetMap, ok := value.(map[string]any) + if !ok { + bsonTarget, docOK := value.(Doc) + if !docOK { + return nil, false + } + targetMap = map[string]any(bsonTarget) + } + + realm, ok := targetMap["realm"].(string) + if !ok || strings.TrimSpace(realm) == "" { + return nil, false + } + + normalized := Doc{"realm": strings.TrimSpace(realm)} + if client, ok := targetMap["client"].(string); ok && strings.TrimSpace(client) != "" { + normalized["client"] = strings.TrimSpace(client) + } + if organizationID, ok := targetMap["organizationId"].(string); ok && strings.TrimSpace(organizationID) != "" { + normalized["organizationId"] = strings.TrimSpace(organizationID) + } + + return normalized, true +} + +func normalizeThemeMetadata(value any) (Doc, bool) { + metadataMap, ok := value.(map[string]any) + if !ok { + bsonMetadata, docOK := value.(Doc) + if !docOK { + return nil, false + } + metadataMap = map[string]any(bsonMetadata) + } + + normalized := Doc{} + for key, val := range metadataMap { + normalized[strings.TrimSpace(key)] = val + } + + return normalized, true +} + +func hasSensitiveThemeMetadata(metadata Doc) bool { + for key := range metadata { + k := strings.ToLower(strings.TrimSpace(key)) + if strings.Contains(k, "secret") || strings.Contains(k, "password") || strings.Contains(k, "token") || strings.Contains(k, "credential") || strings.Contains(k, "filesystem") || strings.Contains(k, "path") { + return true + } + } + return false +} + +func authorizeKeycloakThemeCreate(claims *accessClaims, doc Doc) error { + scope, _ := doc["scope"].(string) + status, _ := doc["status"].(string) + + if scope == keycloakThemeScopePlatform { + if !hasAnyRole(claims, "platform_owner", "platform_admin") { + return errors.New("only platform internal roles can create platform themes") + } + if !hasPermission(claims, permKeycloakThemesCreate) || !hasPermission(claims, permKeycloakThemesManagePlatformDefaults) { + return errors.New("missing permissions for platform theme management") + } + if status == keycloakThemeStatusPublished && !hasPermission(claims, permKeycloakThemesPublish) { + return errors.New("missing permission: keycloak_themes.publish") + } + return nil + } + + if scope == keycloakThemeScopeOrganization { + if !hasPermission(claims, permKeycloakThemesCreate) || !hasPermission(claims, permKeycloakThemesManageOrgOverrides) { + return errors.New("missing permissions for organisation theme management") + } + + isPlatformRole := hasAnyRole(claims, "platform_owner", "platform_admin") + if !isPlatformRole { + if !hasAnyRole(claims, "organisation_owner", "organisation_admin") { + return errors.New("only organisation admins can manage organisation-scoped themes") + } + if status != keycloakThemeStatusDraft { + return errors.New("organisation admins can only manage draft organisation themes") + } + } + + if status == keycloakThemeStatusPublished && !hasPermission(claims, permKeycloakThemesPublish) { + return errors.New("missing permission: keycloak_themes.publish") + } + return nil + } + + return errors.New("invalid theme scope") +} + +func authorizeKeycloakThemePatch(claims *accessClaims, existing, patch Doc) error { + scope, _ := existing["scope"].(string) + if scopePatch, ok := patch["scope"].(string); ok { + scope = scopePatch + } + + status, _ := existing["status"].(string) + if statusPatch, ok := patch["status"].(string); ok { + status = statusPatch + } + + if scope == keycloakThemeScopePlatform { + if !hasAnyRole(claims, "platform_owner", "platform_admin") { + return errors.New("only platform internal roles can edit platform themes") + } + if !hasPermission(claims, permKeycloakThemesEdit) || !hasPermission(claims, permKeycloakThemesManagePlatformDefaults) { + return errors.New("missing permissions for platform theme management") + } + if status == keycloakThemeStatusPublished && !hasPermission(claims, permKeycloakThemesPublish) { + return errors.New("missing permission: keycloak_themes.publish") + } + return nil + } + + if !hasPermission(claims, permKeycloakThemesEdit) || !hasPermission(claims, permKeycloakThemesManageOrgOverrides) { + return errors.New("missing permissions for organisation theme management") + } + + isPlatformRole := hasAnyRole(claims, "platform_owner", "platform_admin") + if !isPlatformRole { + if !hasAnyRole(claims, "organisation_owner", "organisation_admin") { + return errors.New("only organisation admins can edit organisation themes") + } + if status != keycloakThemeStatusDraft { + return errors.New("organisation admins can only manage draft organisation themes") + } + } + + if status == keycloakThemeStatusPublished && !hasPermission(claims, permKeycloakThemesPublish) { + return errors.New("missing permission: keycloak_themes.publish") + } + + return nil +} + +func authorizeKeycloakThemeDelete(claims *accessClaims, existing Doc) error { + scope, _ := existing["scope"].(string) + status, _ := existing["status"].(string) + + if scope == keycloakThemeScopePlatform { + if !hasAnyRole(claims, "platform_owner", "platform_admin") { + return errors.New("only platform internal roles can delete platform themes") + } + if !hasPermission(claims, permKeycloakThemesArchive) || !hasPermission(claims, permKeycloakThemesManagePlatformDefaults) { + return errors.New("missing permissions for platform theme management") + } + return nil + } + + if !hasPermission(claims, permKeycloakThemesArchive) || !hasPermission(claims, permKeycloakThemesManageOrgOverrides) { + return errors.New("missing permissions for organisation theme management") + } + + isPlatformRole := hasAnyRole(claims, "platform_owner", "platform_admin") + if !isPlatformRole { + if !hasAnyRole(claims, "organisation_owner", "organisation_admin") { + return errors.New("only organisation admins can delete organisation themes") + } + if status != keycloakThemeStatusDraft { + return errors.New("organisation admins can only manage draft organisation themes") + } + } + + return nil +} + +func sanitizeThemeDocument(doc Doc, claims *accessClaims) Doc { + if hasAnyRole(claims, "platform_owner", "platform_admin", "admin") { + return doc + } + + sanitized := Doc{} + for key, value := range doc { + if isSensitiveThemeField(key) { + continue + } + if key == "metadata" { + metadata, ok := value.(Doc) + if !ok { + sanitized[key] = value + continue + } + clean := Doc{} + for mk, mv := range metadata { + if !isSensitiveThemeField(mk) { + clean[mk] = mv + } + } + sanitized[key] = clean + continue + } + sanitized[key] = value + } + + return sanitized +} + +func isSensitiveThemeField(key string) bool { + k := strings.ToLower(strings.TrimSpace(key)) + return strings.Contains(k, "secret") || strings.Contains(k, "password") || strings.Contains(k, "token") || strings.Contains(k, "credential") || strings.Contains(k, "filesystem") || strings.Contains(k, "path") +} + +func stampThemeEditorMetadata(doc, existing Doc, claims *accessClaims) { + metadata := resolveThemeMetadata(existing, doc) + if metadata == nil { + metadata = Doc{} + } + + now := time.Now().UnixMilli() + if _, exists := metadata["createdAt"]; !exists { + metadata["createdAt"] = now + } + if _, exists := metadata["createdBy"]; !exists { + metadata["createdBy"] = themeEditorActor(claims) + } + metadata["updatedAt"] = now + metadata["updatedBy"] = themeEditorActor(claims) + + doc["metadata"] = metadata +} + +func themeEditorActor(claims *accessClaims) string { + if claims == nil { + return "unknown" + } + if strings.TrimSpace(claims.PreferredUsername) != "" { + return strings.TrimSpace(claims.PreferredUsername) + } + if strings.TrimSpace(claims.Email) != "" { + return strings.TrimSpace(claims.Email) + } + if strings.TrimSpace(claims.Sub) != "" { + return strings.TrimSpace(claims.Sub) + } + return "unknown" +} diff --git a/Backend/cmd/api/live_courses.go b/Backend/cmd/api/live_courses.go new file mode 100644 index 0000000..663229f --- /dev/null +++ b/Backend/cmd/api/live_courses.go @@ -0,0 +1,1394 @@ +package main + +import ( + "errors" + "net/http" + "strings" + "time" + + "github.com/go-chi/chi/v5" +) + +const ( + liveCourseStatusDraft = "draft" + liveCourseStatusScheduled = "scheduled" + liveCourseStatusPublished = "published" + liveCourseStatusArchived = "archived" +) + +func (a *app) liveCourseManagerMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + claims, _ := r.Context().Value(userClaimsKey).(*accessClaims) + if claims == nil { + writeError(w, http.StatusUnauthorized, "no authenticated user") + return + } + if !hasRole(claims, "admin") && !hasRole(claims, "instructor") { + writeError(w, http.StatusForbidden, "insufficient role for live course management") + return + } + next.ServeHTTP(w, r) + }) +} + +func (a *app) liveCourseLearnerMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + claims, _ := r.Context().Value(userClaimsKey).(*accessClaims) + if claims == nil { + writeError(w, http.StatusUnauthorized, "no authenticated user") + return + } + if !hasRole(claims, "learner") && !hasRole(claims, "admin") && !hasRole(claims, "instructor") { + writeError(w, http.StatusForbidden, "insufficient role for learner live course access") + return + } + next.ServeHTTP(w, r) + }) +} + +func (a *app) listLearnerLiveCourses(w http.ResponseWriter, r *http.Request) { + if a.db == nil { + writeError(w, http.StatusServiceUnavailable, "database not configured") + return + } + + userID, ok := a.learnerSubjectFromContext(r) + if !ok { + writeError(w, http.StatusUnauthorized, "missing learner subject") + return + } + + enrollments, err := a.list("live_course_enrollments", Doc{ + "userId": userID, + "status": Doc{"$in": List{"enrolled", "completed"}}, + }, Sort{{Key: "updatedAt", Value: -1}}) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to list learner enrollments") + return + } + if len(enrollments) == 0 { + writeJSON(w, http.StatusOK, []Doc{}) + return + } + + courseIDs := make(map[string]struct{}) + cohortIDs := make(map[string]struct{}) + for _, enrollment := range enrollments { + if courseID, ok := courseIDFromDocument(enrollment); ok { + courseIDs[courseID] = struct{}{} + } + if cohortID, ok := asString(enrollment["cohortId"]); ok { + cohortIDs[cohortID] = struct{}{} + } + } + + courses, err := a.list("live_courses", Doc{"id": Doc{"$in": mapKeys(courseIDs)}}, Sort{{Key: "updatedAt", Value: -1}}) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to load live courses") + return + } + cohorts, err := a.list("live_course_cohorts", Doc{"id": Doc{"$in": mapKeys(cohortIDs)}}, Sort{{Key: "startsAt", Value: 1}}) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to load cohorts") + return + } + + courseByID := indexByID(courses) + cohortByID := indexByID(cohorts) + + now := time.Now().UTC().UnixMilli() + sessions, err := a.list("live_course_sessions", Doc{ + "$or": List{ + Doc{"liveCourseId": Doc{"$in": mapKeys(courseIDs)}}, + Doc{"courseId": Doc{"$in": mapKeys(courseIDs)}}, + }, + "cohortId": Doc{"$in": mapKeys(cohortIDs)}, + "status": Doc{"$in": List{"scheduled", "live"}}, + "startsAt": Doc{"$gte": now}, + }, Sort{{Key: "startsAt", Value: 1}}) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to load sessions") + return + } + + upcomingByCourseCohort := make(map[string][]Doc) + for _, session := range sessions { + courseID, _ := courseIDFromDocument(session) + cohortID, _ := asString(session["cohortId"]) + key := compositeScopeKey(courseID, cohortID) + upcomingByCourseCohort[key] = append(upcomingByCourseCohort[key], session) + } + + items := make([]Doc, 0, len(enrollments)) + for _, enrollment := range enrollments { + courseID, _ := courseIDFromDocument(enrollment) + cohortID, _ := asString(enrollment["cohortId"]) + course := courseByID[courseID] + cohort := cohortByID[cohortID] + entitlementAllowed, entitlementReason, entitlementMessage, err := a.learnerEntitlementDecision(r, userID, resolveEntitlementKey(course, cohort)) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to evaluate learner entitlement") + return + } + access := evaluateLearnerAccess(course, cohort, enrollment, entitlementAllowed, entitlementReason, entitlementMessage) + items = append(items, Doc{ + "course": course, + "cohort": cohort, + "enrollment": enrollment, + "upcomingSessions": upcomingByCourseCohort[compositeScopeKey(courseID, cohortID)], + "access": access, + }) + } + + writeJSON(w, http.StatusOK, items) +} + +func (a *app) getLearnerLiveCourse(w http.ResponseWriter, r *http.Request) { + if a.db == nil { + writeError(w, http.StatusServiceUnavailable, "database not configured") + return + } + + userID, ok := a.learnerSubjectFromContext(r) + if !ok { + writeError(w, http.StatusUnauthorized, "missing learner subject") + return + } + + courseID := strings.TrimSpace(chi.URLParam(r, "id")) + if courseID == "" { + writeError(w, http.StatusBadRequest, "live course id is required") + return + } + + course, err := a.getByID("live_courses", courseID) + if err != nil { + if errors.Is(err, ErrNotFound) { + writeError(w, http.StatusNotFound, "live course not found") + return + } + writeError(w, http.StatusInternalServerError, "failed to load live course") + return + } + + enrollments, err := a.list("live_course_enrollments", Doc{ + "userId": userID, + "$or": List{ + Doc{"liveCourseId": courseID}, + Doc{"courseId": courseID}, + }, + "status": Doc{"$in": List{"enrolled", "completed"}}, + }, Sort{{Key: "updatedAt", Value: -1}}) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to load learner enrollments") + return + } + + if len(enrollments) == 0 && !isPublicLiveCourse(course, time.Now().UTC()) { + writeError(w, http.StatusNotFound, "live course not found") + return + } + + cohortIDs := make(map[string]struct{}) + for _, enrollment := range enrollments { + if cohortID, ok := asString(enrollment["cohortId"]); ok { + cohortIDs[cohortID] = struct{}{} + } + } + + cohorts := []Doc{} + if len(cohortIDs) > 0 { + cohorts, err = a.list("live_course_cohorts", Doc{"id": Doc{"$in": mapKeys(cohortIDs)}}, Sort{{Key: "startsAt", Value: 1}}) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to load cohorts") + return + } + } + + entitlementAllowed, entitlementReason, entitlementMessage, err := a.learnerEntitlementDecision(r, userID, resolveEntitlementKey(course, nil)) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to evaluate learner entitlement") + return + } + access := evaluateLearnerAccess(course, nil, firstDocument(enrollments), entitlementAllowed, entitlementReason, entitlementMessage) + writeJSON(w, http.StatusOK, Doc{ + "course": course, + "enrollments": enrollments, + "cohorts": cohorts, + "access": access, + }) +} + +func (a *app) listLearnerLiveCourseSessions(w http.ResponseWriter, r *http.Request) { + if a.db == nil { + writeError(w, http.StatusServiceUnavailable, "database not configured") + return + } + + userID, ok := a.learnerSubjectFromContext(r) + if !ok { + writeError(w, http.StatusUnauthorized, "missing learner subject") + return + } + + courseID := strings.TrimSpace(chi.URLParam(r, "id")) + if courseID == "" { + writeError(w, http.StatusBadRequest, "live course id is required") + return + } + + course, err := a.getByID("live_courses", courseID) + if err != nil { + if errors.Is(err, ErrNotFound) { + writeError(w, http.StatusNotFound, "live course not found") + return + } + writeError(w, http.StatusInternalServerError, "failed to load live course") + return + } + + enrollments, err := a.list("live_course_enrollments", Doc{ + "userId": userID, + "$or": List{ + Doc{"liveCourseId": courseID}, + Doc{"courseId": courseID}, + }, + "status": Doc{"$in": List{"enrolled", "completed"}}, + }, Sort{{Key: "updatedAt", Value: -1}}) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to load learner enrollments") + return + } + + if len(enrollments) == 0 && !isPublicLiveCourse(course, time.Now().UTC()) { + writeError(w, http.StatusNotFound, "live course not found") + return + } + + cohorts, err := a.list("live_course_cohorts", Doc{"$or": List{Doc{"liveCourseId": courseID}, Doc{"courseId": courseID}}}, Sort{{Key: "startsAt", Value: 1}}) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to load cohorts") + return + } + cohortByID := indexByID(cohorts) + enrollmentByCohort := indexEnrollmentsByCohort(enrollments) + + now := time.Now().UTC().UnixMilli() + sessions, err := a.list("live_course_sessions", Doc{ + "$or": List{Doc{"liveCourseId": courseID}, Doc{"courseId": courseID}}, + "status": Doc{"$in": List{"scheduled", "live"}}, + "startsAt": Doc{"$gte": now}, + }, Sort{{Key: "startsAt", Value: 1}}) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to load sessions") + return + } + + items := make([]Doc, 0, len(sessions)) + for _, session := range sessions { + cohortID, _ := asString(session["cohortId"]) + cohort := cohortByID[cohortID] + enrollment := enrollmentByCohort[cohortID] + entitlementAllowed, entitlementReason, entitlementMessage, err := a.learnerEntitlementDecision(r, userID, resolveEntitlementKey(course, cohort)) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to evaluate learner entitlement") + return + } + access := evaluateLearnerSessionAccess(course, cohort, session, enrollment, entitlementAllowed, entitlementReason, entitlementMessage) + items = append(items, Doc{ + "session": session, + "cohort": cohort, + "access": access, + }) + } + + writeJSON(w, http.StatusOK, items) +} + +func (a *app) getLearnerLiveCourseSession(w http.ResponseWriter, r *http.Request) { + if a.db == nil { + writeError(w, http.StatusServiceUnavailable, "database not configured") + return + } + + userID, ok := a.learnerSubjectFromContext(r) + if !ok { + writeError(w, http.StatusUnauthorized, "missing learner subject") + return + } + + courseID := strings.TrimSpace(chi.URLParam(r, "id")) + sessionID := strings.TrimSpace(chi.URLParam(r, "sessionId")) + if courseID == "" || sessionID == "" { + writeError(w, http.StatusBadRequest, "live course id and session id are required") + return + } + + course, session, cohort, enrollment, err := a.loadLearnerSessionContext(userID, courseID, sessionID) + if err != nil { + a.writeLearnerSessionContextError(w, err) + return + } + + entitlementAllowed, entitlementReason, entitlementMessage, err := a.learnerEntitlementDecision(r, userID, resolveEntitlementKey(course, cohort)) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to evaluate learner entitlement") + return + } + access := evaluateLearnerSessionAccess(course, cohort, session, enrollment, entitlementAllowed, entitlementReason, entitlementMessage) + writeJSON(w, http.StatusOK, Doc{ + "course": course, + "cohort": cohort, + "session": session, + "enrollment": enrollment, + "access": access, + }) +} + +func (a *app) joinLearnerLiveCourseSession(w http.ResponseWriter, r *http.Request) { + if a.db == nil { + writeError(w, http.StatusServiceUnavailable, "database not configured") + return + } + + userID, ok := a.learnerSubjectFromContext(r) + if !ok { + writeError(w, http.StatusUnauthorized, "missing learner subject") + return + } + + courseID := strings.TrimSpace(chi.URLParam(r, "id")) + sessionID := strings.TrimSpace(chi.URLParam(r, "sessionId")) + if courseID == "" || sessionID == "" { + writeError(w, http.StatusBadRequest, "live course id and session id are required") + return + } + + course, session, cohort, enrollment, err := a.loadLearnerSessionContext(userID, courseID, sessionID) + if err != nil { + a.writeLearnerSessionContextError(w, err) + return + } + + entitlementAllowed, entitlementReason, entitlementMessage, err := a.learnerEntitlementDecision(r, userID, resolveEntitlementKey(course, cohort)) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to evaluate learner entitlement") + return + } + access := evaluateLearnerSessionAccess(course, cohort, session, enrollment, entitlementAllowed, entitlementReason, entitlementMessage) + allowed, _ := access["allowed"].(bool) + if !allowed { + message, _ := access["message"].(string) + if message == "" { + message = "session is locked" + } + writeError(w, http.StatusForbidden, message) + return + } + + meetingURL, _ := asString(session["meetingUrl"]) + writeJSON(w, http.StatusOK, learnerLiveCourseJoinResponse{ + Allowed: true, + CourseID: courseID, + SessionID: sessionID, + JoinURL: meetingURL, + }) +} + +func (a *app) listLiveCourses(w http.ResponseWriter, r *http.Request) { + a.listCollection("live_courses", w) +} + +func (a *app) listPublicLiveCourses(w http.ResponseWriter, r *http.Request) { + if a.db == nil { + writeError(w, http.StatusServiceUnavailable, "database not configured") + return + } + + docs, err := a.list("live_courses", Doc{"status": Doc{"$in": List{liveCourseStatusPublished, liveCourseStatusScheduled}}}, Sort{{Key: "updatedAt", Value: -1}, {Key: "createdAt", Value: -1}}) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to list live courses") + return + } + + publicCourses := make([]Doc, 0, len(docs)) + for _, doc := range docs { + if isPublicLiveCourse(doc, time.Now().UTC()) { + publicCourses = append(publicCourses, doc) + } + } + + writeJSON(w, http.StatusOK, publicCourses) +} + +func (a *app) getPublicLiveCourseBySlug(w http.ResponseWriter, r *http.Request) { + if a.db == nil { + writeError(w, http.StatusServiceUnavailable, "database not configured") + return + } + + doc, err := a.getPublicLiveCourseBySlugOrNotFound(strings.TrimSpace(chi.URLParam(r, "slug"))) + if err != nil { + if errors.Is(err, ErrNotFound) { + writeError(w, http.StatusNotFound, "live course not found") + return + } + writeError(w, http.StatusInternalServerError, "failed to load live course") + return + } + + writeJSON(w, http.StatusOK, doc) +} + +func (a *app) listPublicLiveCourseCohortsBySlug(w http.ResponseWriter, r *http.Request) { + if a.db == nil { + writeError(w, http.StatusServiceUnavailable, "database not configured") + return + } + + course, err := a.getPublicLiveCourseBySlugOrNotFound(strings.TrimSpace(chi.URLParam(r, "slug"))) + if err != nil { + if errors.Is(err, ErrNotFound) { + writeError(w, http.StatusNotFound, "live course not found") + return + } + writeError(w, http.StatusInternalServerError, "failed to load live course") + return + } + + liveCourseID, _ := asString(course["id"]) + if liveCourseID == "" { + writeError(w, http.StatusNotFound, "live course not found") + return + } + + docs, err := a.list("live_course_cohorts", Doc{"$or": List{Doc{"liveCourseId": liveCourseID}, Doc{"courseId": liveCourseID}}}, Sort{{Key: "startsAt", Value: 1}, {Key: "updatedAt", Value: -1}}) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to list cohorts") + return + } + + cohorts := make([]Doc, 0, len(docs)) + for _, doc := range docs { + if isOpenPublicLiveCourseCohort(doc, time.Now().UTC()) { + cohorts = append(cohorts, doc) + } + } + + writeJSON(w, http.StatusOK, cohorts) +} + +func (a *app) createLiveCourse(w http.ResponseWriter, r *http.Request) { + var payload liveCourseUpsertRequest + if err := decodeJSON(r, &payload); err != nil { + writeError(w, http.StatusBadRequest, "invalid payload") + return + } + + doc, err := prepareLiveCoursePayload(payload.Doc(), false) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + + doc, err = a.create("live_courses", doc) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to create resource") + return + } + writeJSON(w, http.StatusCreated, doc) +} + +func (a *app) getLiveCourse(w http.ResponseWriter, r *http.Request) { + a.getCollection("live_courses", w, r) +} + +func (a *app) patchLiveCourse(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + var patch liveCourseUpsertRequest + if err := decodeJSON(r, &patch); err != nil { + writeError(w, http.StatusBadRequest, "invalid patch payload") + return + } + + doc, err := prepareLiveCoursePayload(patch.Doc(), true) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + + doc, err = a.patch("live_courses", id, doc) + 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) deleteLiveCourse(w http.ResponseWriter, r *http.Request) { + a.deleteCollection("live_courses", w, r) +} + +func (a *app) listLiveCourseCohorts(w http.ResponseWriter, r *http.Request) { + courseID := strings.TrimSpace(chi.URLParam(r, "id")) + if courseID == "" { + writeError(w, http.StatusBadRequest, "live course id is required") + return + } + + docs, err := a.list("live_course_cohorts", Doc{"liveCourseId": courseID}, Sort{{Key: "startsAt", Value: 1}, {Key: "updatedAt", Value: -1}}) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to list cohorts") + return + } + writeJSON(w, http.StatusOK, docs) +} + +func (a *app) createLiveCourseCohort(w http.ResponseWriter, r *http.Request) { + courseID := strings.TrimSpace(chi.URLParam(r, "id")) + if courseID == "" { + writeError(w, http.StatusBadRequest, "live course id is required") + return + } + if _, err := a.getByID("live_courses", courseID); err != nil { + if errors.Is(err, ErrNotFound) { + writeError(w, http.StatusNotFound, "live course not found") + return + } + writeError(w, http.StatusInternalServerError, "failed to load live course") + return + } + + var payload liveCourseCohortUpsertRequest + if err := decodeJSON(r, &payload); err != nil { + writeError(w, http.StatusBadRequest, "invalid payload") + return + } + + doc, err := prepareLiveCourseCohortPayload(payload.Doc(), false) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + doc["liveCourseId"] = courseID + + doc, err = a.create("live_course_cohorts", doc) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to create resource") + return + } + writeJSON(w, http.StatusCreated, doc) +} + +func (a *app) getLiveCourseCohort(w http.ResponseWriter, r *http.Request) { + courseID := strings.TrimSpace(chi.URLParam(r, "id")) + cohortID := strings.TrimSpace(chi.URLParam(r, "cohortId")) + doc, err := a.getByID("live_course_cohorts", cohortID) + 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 + } + + if liveCourseID, _ := asString(doc["liveCourseId"]); liveCourseID != courseID { + writeError(w, http.StatusNotFound, "resource not found") + return + } + writeJSON(w, http.StatusOK, doc) +} + +func (a *app) patchLiveCourseCohort(w http.ResponseWriter, r *http.Request) { + courseID := strings.TrimSpace(chi.URLParam(r, "id")) + cohortID := strings.TrimSpace(chi.URLParam(r, "cohortId")) + existing, err := a.getByID("live_course_cohorts", cohortID) + 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 + } + if liveCourseID, _ := asString(existing["liveCourseId"]); liveCourseID != courseID { + writeError(w, http.StatusNotFound, "resource not found") + return + } + + var patch liveCourseCohortUpsertRequest + if err := decodeJSON(r, &patch); err != nil { + writeError(w, http.StatusBadRequest, "invalid patch payload") + return + } + + doc, err := prepareLiveCourseCohortPayload(patch.Doc(), true) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + delete(doc, "liveCourseId") + + doc, err = a.patch("live_course_cohorts", cohortID, doc) + 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) deleteLiveCourseCohort(w http.ResponseWriter, r *http.Request) { + courseID := strings.TrimSpace(chi.URLParam(r, "id")) + cohortID := strings.TrimSpace(chi.URLParam(r, "cohortId")) + existing, err := a.getByID("live_course_cohorts", cohortID) + 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 + } + if liveCourseID, _ := asString(existing["liveCourseId"]); liveCourseID != courseID { + writeError(w, http.StatusNotFound, "resource not found") + return + } + + if err := a.deleteByID("live_course_cohorts", cohortID); 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) listLiveCourseSessions(w http.ResponseWriter, r *http.Request) { + courseID := strings.TrimSpace(chi.URLParam(r, "id")) + cohortID := strings.TrimSpace(chi.URLParam(r, "cohortId")) + + docs, err := a.list("live_course_sessions", Doc{"liveCourseId": courseID, "cohortId": cohortID}, Sort{{Key: "startsAt", Value: 1}, {Key: "updatedAt", Value: -1}}) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to list sessions") + return + } + writeJSON(w, http.StatusOK, docs) +} + +func (a *app) createLiveCourseSession(w http.ResponseWriter, r *http.Request) { + courseID := strings.TrimSpace(chi.URLParam(r, "id")) + cohortID := strings.TrimSpace(chi.URLParam(r, "cohortId")) + + cohort, err := a.getByID("live_course_cohorts", cohortID) + if err != nil { + if errors.Is(err, ErrNotFound) { + writeError(w, http.StatusNotFound, "cohort not found") + return + } + writeError(w, http.StatusInternalServerError, "failed to load cohort") + return + } + if liveCourseID, _ := asString(cohort["liveCourseId"]); liveCourseID != courseID { + writeError(w, http.StatusNotFound, "cohort not found") + return + } + + var payload liveCourseSessionUpsertRequest + if err := decodeJSON(r, &payload); err != nil { + writeError(w, http.StatusBadRequest, "invalid payload") + return + } + + doc, err := prepareLiveCourseSessionPayload(payload.Doc(), false) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + doc["liveCourseId"] = courseID + doc["cohortId"] = cohortID + + doc, err = a.create("live_course_sessions", doc) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to create resource") + return + } + writeJSON(w, http.StatusCreated, doc) +} + +func (a *app) getLiveCourseSession(w http.ResponseWriter, r *http.Request) { + courseID := strings.TrimSpace(chi.URLParam(r, "id")) + cohortID := strings.TrimSpace(chi.URLParam(r, "cohortId")) + sessionID := strings.TrimSpace(chi.URLParam(r, "sessionId")) + + doc, err := a.getByID("live_course_sessions", sessionID) + 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 + } + + if liveCourseID, _ := asString(doc["liveCourseId"]); liveCourseID != courseID { + writeError(w, http.StatusNotFound, "resource not found") + return + } + if sessionCohortID, _ := asString(doc["cohortId"]); sessionCohortID != cohortID { + writeError(w, http.StatusNotFound, "resource not found") + return + } + + writeJSON(w, http.StatusOK, doc) +} + +func (a *app) patchLiveCourseSession(w http.ResponseWriter, r *http.Request) { + courseID := strings.TrimSpace(chi.URLParam(r, "id")) + cohortID := strings.TrimSpace(chi.URLParam(r, "cohortId")) + sessionID := strings.TrimSpace(chi.URLParam(r, "sessionId")) + + existing, err := a.getByID("live_course_sessions", sessionID) + 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 + } + if liveCourseID, _ := asString(existing["liveCourseId"]); liveCourseID != courseID { + writeError(w, http.StatusNotFound, "resource not found") + return + } + if sessionCohortID, _ := asString(existing["cohortId"]); sessionCohortID != cohortID { + writeError(w, http.StatusNotFound, "resource not found") + return + } + + var patch liveCourseSessionUpsertRequest + if err := decodeJSON(r, &patch); err != nil { + writeError(w, http.StatusBadRequest, "invalid patch payload") + return + } + + doc, err := prepareLiveCourseSessionPayload(patch.Doc(), true) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + delete(doc, "liveCourseId") + delete(doc, "cohortId") + + doc, err = a.patch("live_course_sessions", sessionID, doc) + 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) deleteLiveCourseSession(w http.ResponseWriter, r *http.Request) { + courseID := strings.TrimSpace(chi.URLParam(r, "id")) + cohortID := strings.TrimSpace(chi.URLParam(r, "cohortId")) + sessionID := strings.TrimSpace(chi.URLParam(r, "sessionId")) + + existing, err := a.getByID("live_course_sessions", sessionID) + 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 + } + if liveCourseID, _ := asString(existing["liveCourseId"]); liveCourseID != courseID { + writeError(w, http.StatusNotFound, "resource not found") + return + } + if sessionCohortID, _ := asString(existing["cohortId"]); sessionCohortID != cohortID { + writeError(w, http.StatusNotFound, "resource not found") + return + } + + if err := a.deleteByID("live_course_sessions", sessionID); 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) listLiveCourseEnrollments(w http.ResponseWriter, r *http.Request) { + courseID := strings.TrimSpace(chi.URLParam(r, "id")) + cohortID := strings.TrimSpace(chi.URLParam(r, "cohortId")) + if _, err := a.getScopedLiveCourseCohort(courseID, cohortID); err != nil { + a.writeScopedLookupError(w, err, "cohort") + return + } + + docs, err := a.list("live_course_enrollments", Doc{"liveCourseId": courseID, "cohortId": cohortID}, Sort{{Key: "updatedAt", Value: -1}}) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to list enrollments") + return + } + writeJSON(w, http.StatusOK, docs) +} + +func (a *app) createLiveCourseEnrollment(w http.ResponseWriter, r *http.Request) { + courseID := strings.TrimSpace(chi.URLParam(r, "id")) + cohortID := strings.TrimSpace(chi.URLParam(r, "cohortId")) + if _, err := a.getScopedLiveCourseCohort(courseID, cohortID); err != nil { + a.writeScopedLookupError(w, err, "cohort") + return + } + + var payload liveCourseEnrollmentUpsertRequest + if err := decodeJSON(r, &payload); err != nil { + writeError(w, http.StatusBadRequest, "invalid payload") + return + } + + doc, err := prepareLiveCourseEnrollmentPayload(payload.Doc(), false) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + doc["liveCourseId"] = courseID + doc["cohortId"] = cohortID + + doc, err = a.create("live_course_enrollments", doc) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to create resource") + return + } + writeJSON(w, http.StatusCreated, doc) +} + +func (a *app) patchLiveCourseEnrollment(w http.ResponseWriter, r *http.Request) { + courseID := strings.TrimSpace(chi.URLParam(r, "id")) + cohortID := strings.TrimSpace(chi.URLParam(r, "cohortId")) + enrollmentID := strings.TrimSpace(chi.URLParam(r, "enrollmentId")) + + if _, err := a.getScopedLiveCourseCohort(courseID, cohortID); err != nil { + a.writeScopedLookupError(w, err, "cohort") + return + } + + existing, err := a.getByID("live_course_enrollments", enrollmentID) + 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 + } + if liveCourseID, _ := asString(existing["liveCourseId"]); liveCourseID != courseID { + writeError(w, http.StatusNotFound, "resource not found") + return + } + if enrollmentCohortID, _ := asString(existing["cohortId"]); enrollmentCohortID != cohortID { + writeError(w, http.StatusNotFound, "resource not found") + return + } + + var patch liveCourseEnrollmentUpsertRequest + if err := decodeJSON(r, &patch); err != nil { + writeError(w, http.StatusBadRequest, "invalid patch payload") + return + } + + doc, err := prepareLiveCourseEnrollmentPayload(patch.Doc(), true) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + delete(doc, "liveCourseId") + delete(doc, "cohortId") + + doc, err = a.patch("live_course_enrollments", enrollmentID, doc) + 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) listLiveCourseAttendance(w http.ResponseWriter, r *http.Request) { + courseID := strings.TrimSpace(chi.URLParam(r, "id")) + cohortID := strings.TrimSpace(chi.URLParam(r, "cohortId")) + sessionID := strings.TrimSpace(chi.URLParam(r, "sessionId")) + if _, err := a.getScopedLiveCourseSession(courseID, cohortID, sessionID); err != nil { + a.writeScopedLookupError(w, err, "session") + return + } + + docs, err := a.list("live_course_attendance", Doc{"liveCourseId": courseID, "cohortId": cohortID, "sessionId": sessionID}, Sort{{Key: "updatedAt", Value: -1}}) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to list attendance") + return + } + writeJSON(w, http.StatusOK, docs) +} + +func (a *app) createLiveCourseAttendance(w http.ResponseWriter, r *http.Request) { + courseID := strings.TrimSpace(chi.URLParam(r, "id")) + cohortID := strings.TrimSpace(chi.URLParam(r, "cohortId")) + sessionID := strings.TrimSpace(chi.URLParam(r, "sessionId")) + if _, err := a.getScopedLiveCourseSession(courseID, cohortID, sessionID); err != nil { + a.writeScopedLookupError(w, err, "session") + return + } + + var payload liveCourseAttendanceUpsertRequest + if err := decodeJSON(r, &payload); err != nil { + writeError(w, http.StatusBadRequest, "invalid payload") + return + } + + doc, err := prepareLiveCourseAttendancePayload(payload.Doc(), false) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + + claims, _ := r.Context().Value(userClaimsKey).(*accessClaims) + if claims != nil && strings.TrimSpace(claims.Sub) != "" { + doc["markedBy"] = strings.TrimSpace(claims.Sub) + } + doc["liveCourseId"] = courseID + doc["cohortId"] = cohortID + doc["sessionId"] = sessionID + + doc, err = a.create("live_course_attendance", doc) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to create resource") + return + } + writeJSON(w, http.StatusCreated, doc) +} + +func (a *app) patchLiveCourseAttendance(w http.ResponseWriter, r *http.Request) { + courseID := strings.TrimSpace(chi.URLParam(r, "id")) + cohortID := strings.TrimSpace(chi.URLParam(r, "cohortId")) + sessionID := strings.TrimSpace(chi.URLParam(r, "sessionId")) + attendanceID := strings.TrimSpace(chi.URLParam(r, "attendanceId")) + + if _, err := a.getScopedLiveCourseSession(courseID, cohortID, sessionID); err != nil { + a.writeScopedLookupError(w, err, "session") + return + } + + existing, err := a.getByID("live_course_attendance", attendanceID) + 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 + } + if liveCourseID, _ := asString(existing["liveCourseId"]); liveCourseID != courseID { + writeError(w, http.StatusNotFound, "resource not found") + return + } + if attendanceCohortID, _ := asString(existing["cohortId"]); attendanceCohortID != cohortID { + writeError(w, http.StatusNotFound, "resource not found") + return + } + if attendanceSessionID, _ := asString(existing["sessionId"]); attendanceSessionID != sessionID { + writeError(w, http.StatusNotFound, "resource not found") + return + } + + var patch liveCourseAttendanceUpsertRequest + if err := decodeJSON(r, &patch); err != nil { + writeError(w, http.StatusBadRequest, "invalid patch payload") + return + } + + doc, err := prepareLiveCourseAttendancePayload(patch.Doc(), true) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + delete(doc, "liveCourseId") + delete(doc, "cohortId") + delete(doc, "sessionId") + + claims, _ := r.Context().Value(userClaimsKey).(*accessClaims) + if claims != nil && strings.TrimSpace(claims.Sub) != "" { + doc["markedBy"] = strings.TrimSpace(claims.Sub) + } + + doc, err = a.patch("live_course_attendance", attendanceID, doc) + 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) getScopedLiveCourseCohort(courseID, cohortID string) (Doc, error) { + cohort, err := a.getByID("live_course_cohorts", cohortID) + if err != nil { + return nil, err + } + if liveCourseID, _ := asString(cohort["liveCourseId"]); liveCourseID != courseID { + return nil, ErrNotFound + } + return cohort, nil +} + +func (a *app) getScopedLiveCourseSession(courseID, cohortID, sessionID string) (Doc, error) { + session, err := a.getByID("live_course_sessions", sessionID) + if err != nil { + return nil, err + } + if liveCourseID, _ := asString(session["liveCourseId"]); liveCourseID != courseID { + return nil, ErrNotFound + } + if sessionCohortID, _ := asString(session["cohortId"]); sessionCohortID != cohortID { + return nil, ErrNotFound + } + return session, nil +} + +func (a *app) writeScopedLookupError(w http.ResponseWriter, err error, resource string) { + if errors.Is(err, ErrNotFound) { + writeError(w, http.StatusNotFound, resource+" not found") + return + } + writeError(w, http.StatusInternalServerError, "failed to load "+resource) +} + +func (a *app) loadLearnerSessionContext(userID, courseID, sessionID string) (Doc, Doc, Doc, Doc, error) { + course, err := a.getByID("live_courses", courseID) + if err != nil { + return nil, nil, nil, nil, err + } + + session, err := a.getByID("live_course_sessions", sessionID) + if err != nil { + return nil, nil, nil, nil, err + } + sessionCourseID, _ := courseIDFromDocument(session) + if sessionCourseID != courseID { + return nil, nil, nil, nil, ErrNotFound + } + + cohortID, _ := asString(session["cohortId"]) + cohort, err := a.getByID("live_course_cohorts", cohortID) + if err != nil { + return nil, nil, nil, nil, err + } + + enrollments, err := a.list("live_course_enrollments", Doc{ + "userId": userID, + "cohortId": cohortID, + "$or": List{ + Doc{"liveCourseId": courseID}, + Doc{"courseId": courseID}, + }, + "status": Doc{"$in": List{"enrolled", "completed"}}, + }, Sort{{Key: "updatedAt", Value: -1}}) + if err != nil { + return nil, nil, nil, nil, err + } + + enrollment := firstDocument(enrollments) + if enrollment == nil && !isPublicLiveCourse(course, time.Now().UTC()) { + return nil, nil, nil, nil, ErrNotFound + } + + return course, session, cohort, enrollment, nil +} + +func (a *app) writeLearnerSessionContextError(w http.ResponseWriter, err error) { + if errors.Is(err, ErrNotFound) { + writeError(w, http.StatusNotFound, "session not found") + return + } + writeError(w, http.StatusInternalServerError, "failed to load learner session") +} + +func (a *app) getPublicLiveCourseBySlugOrNotFound(slug string) (Doc, error) { + if slug == "" { + return nil, ErrNotFound + } + + docs, err := a.list("live_courses", Doc{"slug": slug}, Sort{{Key: "updatedAt", Value: -1}}) + if err != nil { + return nil, err + } + if len(docs) == 0 { + return nil, ErrNotFound + } + if !isPublicLiveCourse(docs[0], time.Now().UTC()) { + return nil, ErrNotFound + } + return docs[0], nil +} + +func isPublicLiveCourse(doc Doc, now time.Time) bool { + status, _ := asString(doc["status"]) + normalizedStatus := strings.TrimSpace(strings.ToLower(status)) + if normalizedStatus != liveCourseStatusPublished && normalizedStatus != liveCourseStatusScheduled { + return false + } + + visibility, _ := asString(doc["visibility"]) + if strings.TrimSpace(strings.ToLower(visibility)) != "public" { + return false + } + + publishedAt, hasPublishedAt := toTime(doc["publishedAt"]) + if hasPublishedAt && publishedAt.After(now) { + return false + } + if normalizedStatus == liveCourseStatusScheduled && !hasPublishedAt { + return false + } + + slug, _ := asString(doc["slug"]) + title, _ := asString(doc["title"]) + return strings.TrimSpace(slug) != "" && strings.TrimSpace(title) != "" +} + +func isOpenPublicLiveCourseCohort(doc Doc, _ time.Time) bool { + status, _ := asString(doc["status"]) + if strings.TrimSpace(strings.ToLower(status)) != "open" { + return false + } + + if organisationID, _ := asString(doc["organisationId"]); strings.TrimSpace(organisationID) != "" { + return false + } + + return true +} + +func (a *app) learnerSubjectFromContext(r *http.Request) (string, bool) { + claims, _ := r.Context().Value(userClaimsKey).(*accessClaims) + if claims == nil { + return "", false + } + + candidates := make([]string, 0, 3) + for _, candidate := range []string{claims.Sub, claims.PreferredUsername, claims.Email} { + if value, ok := asString(candidate); ok { + candidates = append(candidates, value) + } + } + if len(candidates) == 0 { + return "", false + } + + tenantID := "" + if tenant, ok := r.Context().Value(tenantContextKey).(tenantContext); ok { + tenantID = strings.TrimSpace(tenant.ID) + } + + if a != nil && a.db != nil { + for _, identity := range candidates { + filter := Doc{ + "$or": List{ + Doc{"id": identity}, + Doc{"keycloakUsername": identity}, + Doc{"email": identity}, + }, + } + if tenantID != "" { + filter["tenantId"] = tenantID + } + + users, err := a.list("users", filter, Sort{{Key: "updatedAt", Value: -1}, {Key: "createdAt", Value: -1}}) + if err != nil || len(users) == 0 { + continue + } + if userID, ok := asString(users[0]["id"]); ok { + return userID, true + } + } + } + + return candidates[0], true +} + +func evaluateLearnerAccess(course, cohort, enrollment Doc, entitlementAllowed bool, entitlementReason, entitlementMessage string) Doc { + if enrollment == nil { + return learnerAccessResult(false, "missing_enrollment", "You are not enrolled in this live course.") + } + + status, _ := asString(enrollment["status"]) + status = strings.ToLower(strings.TrimSpace(status)) + if status != "enrolled" && status != "completed" { + return learnerAccessResult(false, "missing_enrollment", "Your enrollment is not active for this cohort.") + } + + if !entitlementAllowed { + if strings.TrimSpace(entitlementReason) == "" { + entitlementReason = "missing_entitlement" + } + if strings.TrimSpace(entitlementMessage) == "" { + entitlementMessage = "Enrollment found, but your entitlement is missing or invalid for this cohort." + } + return learnerAccessResult(false, entitlementReason, entitlementMessage) + } + + return learnerAccessResult(true, "", "") +} + +func evaluateLearnerSessionAccess(course, cohort, session, enrollment Doc, entitlementAllowed bool, entitlementReason, entitlementMessage string) Doc { + base := evaluateLearnerAccess(course, cohort, enrollment, entitlementAllowed, entitlementReason, entitlementMessage) + allowed, _ := base["allowed"].(bool) + if !allowed { + return base + } + + meetingURL, _ := asString(session["meetingUrl"]) + if meetingURL == "" { + return learnerAccessResult(false, "session_unavailable", "This session does not have an active join link yet.") + } + + status, _ := asString(session["status"]) + normalized := strings.ToLower(strings.TrimSpace(status)) + if normalized != "scheduled" && normalized != "live" { + return learnerAccessResult(false, "session_unavailable", "This session is not currently joinable.") + } + + return learnerAccessResult(true, "", "") +} + +func learnerAccessResult(allowed bool, reason, message string) Doc { + result := Doc{"allowed": allowed} + if reason != "" { + result["reason"] = reason + } + if message != "" { + result["message"] = message + } + return result +} + +func resolveEntitlementKey(course, cohort Doc) string { + if cohort != nil { + if entitlementKey, ok := asString(cohort["entitlementKey"]); ok { + return entitlementKey + } + } + if course != nil { + if entitlementKey, ok := asString(course["entitlementKey"]); ok { + return entitlementKey + } + } + return "" +} + +func mapKeys(values map[string]struct{}) []string { + out := make([]string, 0, len(values)) + for key := range values { + if strings.TrimSpace(key) != "" { + out = append(out, key) + } + } + return out +} + +func indexByID(docs []Doc) map[string]Doc { + out := make(map[string]Doc, len(docs)) + for _, doc := range docs { + if id, ok := asString(doc["id"]); ok { + out[id] = doc + } + } + return out +} + +func indexEnrollmentsByCohort(docs []Doc) map[string]Doc { + out := make(map[string]Doc, len(docs)) + for _, doc := range docs { + cohortID, ok := asString(doc["cohortId"]) + if !ok { + continue + } + if _, exists := out[cohortID]; !exists { + out[cohortID] = doc + } + } + return out +} + +func firstDocument(docs []Doc) Doc { + if len(docs) == 0 { + return nil + } + return docs[0] +} + +func courseIDFromDocument(doc Doc) (string, bool) { + if doc == nil { + return "", false + } + if value, ok := asString(doc["liveCourseId"]); ok { + return value, true + } + if value, ok := asString(doc["courseId"]); ok { + return value, true + } + return "", false +} + +func compositeScopeKey(courseID, cohortID string) string { + return strings.TrimSpace(courseID) + "::" + strings.TrimSpace(cohortID) +} + +func toTime(value any) (time.Time, bool) { + switch v := value.(type) { + case int64: + return time.UnixMilli(v).UTC(), true + case int32: + return time.UnixMilli(int64(v)).UTC(), true + case int: + return time.UnixMilli(int64(v)).UTC(), true + case float64: + if v < 0 { + return time.Time{}, false + } + return time.UnixMilli(int64(v)).UTC(), true + case string: + parsed, err := time.Parse(time.RFC3339, strings.TrimSpace(v)) + if err != nil { + return time.Time{}, false + } + return parsed.UTC(), true + default: + return time.Time{}, false + } +} diff --git a/Backend/cmd/api/live_courses_payloads.go b/Backend/cmd/api/live_courses_payloads.go new file mode 100644 index 0000000..6098817 --- /dev/null +++ b/Backend/cmd/api/live_courses_payloads.go @@ -0,0 +1,486 @@ +package main + +import ( + "errors" + "strings" + "time" +) + +func prepareLiveCoursePayload(payload Doc, partial bool) (Doc, error) { + if payload == nil { + payload = Doc{} + } + + title, hasTitle, err := getOptionalString(payload, "title") + if err != nil { + return nil, errors.New("title must be a string") + } + if !partial && (!hasTitle || title == "") { + return nil, errors.New("title is required") + } + if hasTitle { + payload["title"] = title + } + + slug, hasSlug, err := getOptionalString(payload, "slug") + if err != nil { + return nil, errors.New("slug must be a string") + } + if !hasSlug && !partial && hasTitle { + slug = slugify(title) + hasSlug = true + } + if hasSlug { + payload["slug"] = normalizeSlug(slug) + } + + status, hasStatus, err := getOptionalString(payload, "status") + if err != nil { + return nil, errors.New("status must be a string") + } + if !partial && !hasStatus { + status = liveCourseStatusDraft + hasStatus = true + } + if hasStatus { + normalizedStatus := strings.ToLower(strings.TrimSpace(status)) + if normalizedStatus != liveCourseStatusDraft && normalizedStatus != liveCourseStatusScheduled && normalizedStatus != liveCourseStatusPublished && normalizedStatus != liveCourseStatusArchived { + return nil, errors.New("status must be one of: draft, scheduled, published, archived") + } + payload["status"] = normalizedStatus + } + + if visibility, has, err := getOptionalString(payload, "visibility"); err != nil { + return nil, errors.New("visibility must be a string") + } else if has { + normalized := strings.ToLower(strings.TrimSpace(visibility)) + switch normalized { + case "public", "authenticated", "private", "organisation": + payload["visibility"] = normalized + default: + return nil, errors.New("visibility must be one of: public, authenticated, private, organisation") + } + } + + if mode, has, err := getOptionalString(payload, "deliveryMode"); err != nil { + return nil, errors.New("deliveryMode must be a string") + } else if has { + normalized := strings.ToLower(strings.TrimSpace(mode)) + switch normalized { + case "online", "in_person", "hybrid": + payload["deliveryMode"] = normalized + default: + return nil, errors.New("deliveryMode must be one of: online, in_person, hybrid") + } + } + + if level, has, err := getOptionalString(payload, "level"); err != nil { + return nil, errors.New("level must be a string") + } else if has { + normalized := strings.ToLower(strings.TrimSpace(level)) + switch normalized { + case "beginner", "intermediate", "advanced": + payload["level"] = normalized + default: + return nil, errors.New("level must be one of: beginner, intermediate, advanced") + } + } + + if instructorIDs, has, err := normalizeStringSlice(payload["instructorIds"]); err != nil { + return nil, errors.New("instructorIds must be an array of strings") + } else if has { + payload["instructorIds"] = instructorIDs + } + + if assetIDs, has, err := normalizeStringSlice(payload["assetIds"]); err != nil { + return nil, errors.New("assetIds must be an array of strings") + } else if has { + payload["assetIds"] = assetIDs + } + + for _, field := range []string{"description", "durationSummary", "communityId", "entitlementKey"} { + value, has, err := getOptionalString(payload, field) + if err != nil { + return nil, errors.New(field + " must be a string") + } + if has { + payload[field] = value + } + } + + return payload, nil +} + +func prepareLiveCourseCohortPayload(payload Doc, partial bool) (Doc, error) { + if payload == nil { + payload = Doc{} + } + + title, hasTitle, err := getOptionalString(payload, "title") + if err != nil { + return nil, errors.New("title must be a string") + } + if !partial && (!hasTitle || title == "") { + return nil, errors.New("title is required") + } + if hasTitle { + payload["title"] = title + } + + status, hasStatus, err := getOptionalString(payload, "status") + if err != nil { + return nil, errors.New("status must be a string") + } + if !partial && !hasStatus { + status = "draft" + hasStatus = true + } + if hasStatus { + normalizedStatus := strings.ToLower(strings.TrimSpace(status)) + switch normalizedStatus { + case "draft", "open", "closed", "in_progress", "completed", "cancelled": + payload["status"] = normalizedStatus + default: + return nil, errors.New("status must be one of: draft, open, closed, in_progress, completed, cancelled") + } + } + + startsAt, hasStartsAt, err := parseOptionalTime(payload["startsAt"]) + if err != nil { + return nil, errors.New("startsAt must be a unix milliseconds number or RFC3339 string") + } + if hasStartsAt { + payload["startsAt"] = startsAt.UnixMilli() + } + + endsAt, hasEndsAt, err := parseOptionalTime(payload["endsAt"]) + if err != nil { + return nil, errors.New("endsAt must be a unix milliseconds number or RFC3339 string") + } + if hasEndsAt { + payload["endsAt"] = endsAt.UnixMilli() + } + + if hasStartsAt && hasEndsAt && endsAt.Before(startsAt) { + return nil, errors.New("endsAt must be greater than or equal to startsAt") + } + + if timezone, has, err := getOptionalString(payload, "timezone"); err != nil { + return nil, errors.New("timezone must be a string") + } else if has { + if _, tzErr := time.LoadLocation(timezone); tzErr != nil { + return nil, errors.New("timezone must be a valid IANA timezone") + } + payload["timezone"] = timezone + } + + if capacity, has, err := getOptionalInteger(payload, "capacity"); err != nil { + return nil, errors.New("capacity must be an integer") + } else if has { + if capacity < 0 { + return nil, errors.New("capacity must be greater than or equal to 0") + } + payload["capacity"] = capacity + } + + if enrolledCount, has, err := getOptionalInteger(payload, "enrolledCount"); err != nil { + return nil, errors.New("enrolledCount must be an integer") + } else if has { + if enrolledCount < 0 { + return nil, errors.New("enrolledCount must be greater than or equal to 0") + } + payload["enrolledCount"] = enrolledCount + } else if !partial { + payload["enrolledCount"] = 0 + } + + if waitlistEnabled, has, err := getOptionalBool(payload, "waitlistEnabled"); err != nil { + return nil, errors.New("waitlistEnabled must be a boolean") + } else if has { + payload["waitlistEnabled"] = waitlistEnabled + } + + for _, field := range []string{"priceId", "entitlementKey", "meetingProvider", "defaultMeetingUrl", "organisationId"} { + value, has, err := getOptionalString(payload, field) + if err != nil { + return nil, errors.New(field + " must be a string") + } + if has { + payload[field] = value + } + } + + return payload, nil +} + +func prepareLiveCourseSessionPayload(payload Doc, partial bool) (Doc, error) { + if payload == nil { + payload = Doc{} + } + + title, hasTitle, err := getOptionalString(payload, "title") + if err != nil { + return nil, errors.New("title must be a string") + } + if !partial && (!hasTitle || title == "") { + return nil, errors.New("title is required") + } + if hasTitle { + payload["title"] = title + } + + if description, has, err := getOptionalString(payload, "description"); err != nil { + return nil, errors.New("description must be a string") + } else if has { + payload["description"] = description + } + + status, hasStatus, err := getOptionalString(payload, "status") + if err != nil { + return nil, errors.New("status must be a string") + } + if !partial && !hasStatus { + status = "scheduled" + hasStatus = true + } + if hasStatus { + normalizedStatus := strings.ToLower(strings.TrimSpace(status)) + switch normalizedStatus { + case "scheduled", "live", "completed", "cancelled": + payload["status"] = normalizedStatus + default: + return nil, errors.New("status must be one of: scheduled, live, completed, cancelled") + } + } + + startsAt, hasStartsAt, err := parseOptionalTime(payload["startsAt"]) + if err != nil { + return nil, errors.New("startsAt must be a unix milliseconds number or RFC3339 string") + } + if hasStartsAt { + payload["startsAt"] = startsAt.UnixMilli() + } + + endsAt, hasEndsAt, err := parseOptionalTime(payload["endsAt"]) + if err != nil { + return nil, errors.New("endsAt must be a unix milliseconds number or RFC3339 string") + } + if hasEndsAt { + payload["endsAt"] = endsAt.UnixMilli() + } + + if hasStartsAt && hasEndsAt && endsAt.Before(startsAt) { + return nil, errors.New("endsAt must be greater than or equal to startsAt") + } + + if timezone, has, err := getOptionalString(payload, "timezone"); err != nil { + return nil, errors.New("timezone must be a string") + } else if has { + if _, tzErr := time.LoadLocation(timezone); tzErr != nil { + return nil, errors.New("timezone must be a valid IANA timezone") + } + payload["timezone"] = timezone + } + + if instructorIDs, has, err := normalizeStringSlice(payload["instructorIds"]); err != nil { + return nil, errors.New("instructorIds must be an array of strings") + } else if has { + payload["instructorIds"] = instructorIDs + } + if assetIDs, has, err := normalizeStringSlice(payload["assetIds"]); err != nil { + return nil, errors.New("assetIds must be an array of strings") + } else if has { + payload["assetIds"] = assetIDs + } + + for _, field := range []string{"meetingUrl", "meetingProvider", "recordingAssetId", "communityThreadId"} { + value, has, err := getOptionalString(payload, field) + if err != nil { + return nil, errors.New(field + " must be a string") + } + if has { + payload[field] = value + } + } + + return payload, nil +} + +func prepareLiveCourseEnrollmentPayload(payload Doc, partial bool) (Doc, error) { + if payload == nil { + payload = Doc{} + } + + userID, hasUserID, err := getOptionalString(payload, "userId") + if err != nil { + return nil, errors.New("userId must be a string") + } + if !partial && (!hasUserID || userID == "") { + return nil, errors.New("userId is required") + } + if hasUserID { + payload["userId"] = userID + } + + status, hasStatus, err := getOptionalString(payload, "status") + if err != nil { + return nil, errors.New("status must be a string") + } + if !partial && !hasStatus { + status = "enrolled" + hasStatus = true + } + if hasStatus { + normalizedStatus := strings.ToLower(strings.TrimSpace(status)) + switch normalizedStatus { + case "invited", "enrolled", "waitlisted", "cancelled", "completed", "removed": + payload["status"] = normalizedStatus + default: + return nil, errors.New("status must be one of: invited, enrolled, waitlisted, cancelled, completed, removed") + } + } + + source, hasSource, err := getOptionalString(payload, "source") + if err != nil { + return nil, errors.New("source must be a string") + } + if !partial && !hasSource { + source = "manual" + hasSource = true + } + if hasSource { + normalizedSource := strings.ToLower(strings.TrimSpace(source)) + switch normalizedSource { + case "manual", "stripe", "subscription", "organisation", "invite": + payload["source"] = normalizedSource + default: + return nil, errors.New("source must be one of: manual, stripe, subscription, organisation, invite") + } + } + + if entitlementKey, has, err := getOptionalString(payload, "entitlementKey"); err != nil { + return nil, errors.New("entitlementKey must be a string") + } else if has { + payload["entitlementKey"] = entitlementKey + } + + enrolledAt, hasEnrolledAt, err := parseOptionalTime(payload["enrolledAt"]) + if err != nil { + return nil, errors.New("enrolledAt must be a unix milliseconds number or RFC3339 string") + } + if hasEnrolledAt { + payload["enrolledAt"] = enrolledAt.UnixMilli() + } + + completedAt, hasCompletedAt, err := parseOptionalTime(payload["completedAt"]) + if err != nil { + return nil, errors.New("completedAt must be a unix milliseconds number or RFC3339 string") + } + if hasCompletedAt { + payload["completedAt"] = completedAt.UnixMilli() + } + + if hasEnrolledAt && hasCompletedAt && completedAt.Before(enrolledAt) { + return nil, errors.New("completedAt must be greater than or equal to enrolledAt") + } + + return payload, nil +} + +func prepareLiveCourseAttendancePayload(payload Doc, partial bool) (Doc, error) { + if payload == nil { + payload = Doc{} + } + + userID, hasUserID, err := getOptionalString(payload, "userId") + if err != nil { + return nil, errors.New("userId must be a string") + } + if !partial && (!hasUserID || userID == "") { + return nil, errors.New("userId is required") + } + if hasUserID { + payload["userId"] = userID + } + + status, hasStatus, err := getOptionalString(payload, "status") + if err != nil { + return nil, errors.New("status must be a string") + } + if !partial && !hasStatus { + status = "present" + hasStatus = true + } + if hasStatus { + normalizedStatus := strings.ToLower(strings.TrimSpace(status)) + switch normalizedStatus { + case "present", "late", "absent", "excused", "completed": + payload["status"] = normalizedStatus + default: + return nil, errors.New("status must be one of: present, late, absent, excused, completed") + } + } + + joinedAt, hasJoinedAt, err := parseOptionalTime(payload["joinedAt"]) + if err != nil { + return nil, errors.New("joinedAt must be a unix milliseconds number or RFC3339 string") + } + if hasJoinedAt { + payload["joinedAt"] = joinedAt.UnixMilli() + } + + leftAt, hasLeftAt, err := parseOptionalTime(payload["leftAt"]) + if err != nil { + return nil, errors.New("leftAt must be a unix milliseconds number or RFC3339 string") + } + if hasLeftAt { + payload["leftAt"] = leftAt.UnixMilli() + } + + if hasJoinedAt && hasLeftAt { + if leftAt.Before(joinedAt) { + return nil, errors.New("leftAt must be greater than or equal to joinedAt") + } + payload["durationSeconds"] = int(leftAt.Sub(joinedAt).Seconds()) + } + + if durationSeconds, has, err := getOptionalInteger(payload, "durationSeconds"); err != nil { + return nil, errors.New("durationSeconds must be an integer") + } else if has { + if durationSeconds < 0 { + return nil, errors.New("durationSeconds must be greater than or equal to 0") + } + payload["durationSeconds"] = durationSeconds + } + + if notes, has, err := getOptionalString(payload, "notes"); err != nil { + return nil, errors.New("notes must be a string") + } else if has { + payload["notes"] = notes + } + + return payload, nil +} + +func getOptionalInteger(payload map[string]any, field string) (int, bool, error) { + raw, ok := payload[field] + if !ok || raw == nil { + return 0, false, nil + } + + switch value := raw.(type) { + case int: + return value, true, nil + case int32: + return int(value), true, nil + case int64: + return int(value), true, nil + case float64: + if value != float64(int(value)) { + return 0, false, errors.New("not an integer") + } + return int(value), true, nil + default: + return 0, false, errors.New("not an integer") + } +} diff --git a/Backend/cmd/api/main.go b/Backend/cmd/api/main.go new file mode 100644 index 0000000..f6e0ac4 --- /dev/null +++ b/Backend/cmd/api/main.go @@ -0,0 +1,40 @@ +package main + +import ( + "context" + "log" + "net/http" + "os/signal" + "syscall" +) + +func main() { + cfg := loadConfig() + app, err := newApp(cfg) + if err != nil { + log.Fatalf("failed to initialize app: %v", err) + } + defer app.close() + + server := &http.Server{ + Addr: ":" + cfg.Port, + Handler: app.handler(), + } + + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer stop() + + go func() { + <-ctx.Done() + shutdownCtx, cancel := context.WithTimeout(context.Background(), cfg.ShutdownTimeout) + defer cancel() + if err := server.Shutdown(shutdownCtx); err != nil { + log.Printf("graceful shutdown failed: %v", err) + } + }() + + log.Printf("starting backend API on :%s", cfg.Port) + if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Fatalf("server failed: %v", err) + } +} diff --git a/Backend/cmd/api/mcp_server.go b/Backend/cmd/api/mcp_server.go new file mode 100644 index 0000000..9cf5feb --- /dev/null +++ b/Backend/cmd/api/mcp_server.go @@ -0,0 +1,278 @@ +package main + +import ( + "encoding/json" + "net/http" +) + +const mcpProtocolVersion = "2025-03-26" + +type mcpRequest struct { + JSONRPC string `json:"jsonrpc"` + ID json.RawMessage `json:"id,omitempty"` + Method string `json:"method"` + Params json.RawMessage `json:"params,omitempty"` +} + +type mcpResponse struct { + JSONRPC string `json:"jsonrpc"` + ID json.RawMessage `json:"id,omitempty"` + Result any `json:"result,omitempty"` + Error *mcpError `json:"error,omitempty"` +} + +type mcpError struct { + Code int `json:"code"` + Message string `json:"message"` +} + +type mcpTool struct { + Name string `json:"name"` + Description string `json:"description"` + InputSchema map[string]any `json:"inputSchema"` +} + +func (a *app) mcpProtectedResourceMetadata(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, http.StatusOK, map[string]any{ + "resource": a.cfg.MCPResourceURL, + "authorization_servers": []string{a.cfg.KeycloakIssuer}, + "scopes_supported": []string{ + "cms.authoring.read", + "cms.authoring.propose", + "cms.authoring.preview", + "scorm.authoring.read", + "scorm.authoring.propose", + "scorm.authoring.preview", + }, + "bearer_methods_supported": []string{"header"}, + }) +} + +func (a *app) authoringRoleMiddleware(next http.Handler) http.Handler { + if a.cfg.AuthDisabled { + return next + } + + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + claims, _ := r.Context().Value(userClaimsKey).(*accessClaims) + if claims == nil { + writeError(w, http.StatusUnauthorized, "authentication required") + return + } + if !hasAnyRole(claims, "editor", "instructor", "admin", "platform_owner", "platform_admin") { + writeError(w, http.StatusForbidden, "authoring role required") + return + } + next.ServeHTTP(w, r) + }) +} + +func (a *app) handleMCP(w http.ResponseWriter, r *http.Request) { + var request mcpRequest + if err := decodeJSON(r, &request); err != nil { + writeMCPError(w, nil, -32700, "invalid JSON-RPC request") + return + } + if request.JSONRPC != "2.0" || request.Method == "" { + writeMCPError(w, request.ID, -32600, "invalid JSON-RPC request") + return + } + + switch request.Method { + case "initialize": + writeMCPResult(w, request.ID, map[string]any{ + "protocolVersion": mcpProtocolVersion, + "capabilities": map[string]any{ + "tools": map[string]any{"listChanged": false}, + "resources": map[string]any{"subscribe": false, "listChanged": false}, + }, + "serverInfo": map[string]any{ + "name": "cms-authoring", + "version": "0.1.0", + }, + }) + case "ping": + writeMCPResult(w, request.ID, map[string]any{}) + case "tools/list": + writeMCPResult(w, request.ID, map[string]any{"tools": externalAuthoringTools()}) + case "resources/list": + writeMCPResult(w, request.ID, map[string]any{"resources": []map[string]any{ + { + "uri": "cms://capabilities", + "name": "CMS authoring capabilities", + "description": "Supported proposal-only site, theme, and SCORM authoring operations.", + "mimeType": "application/json", + }, + { + "uri": "cms://themes/schema", + "name": "CMS theme schema", + "description": "Allowed semantic theme tokens and validation constraints.", + "mimeType": "application/json", + }, + { + "uri": "cms://sites/schema", + "name": "CMS site proposal schema", + "description": "Allowed site structure and page proposal fields.", + "mimeType": "application/json", + }, + }}) + case "resources/read": + if !a.mcpScopeAllowed(r, "cms.authoring.read") { + writeMCPError(w, request.ID, -32001, "insufficient OAuth scope") + return + } + a.handleMCPResourceRead(w, request) + case "tools/call": + a.handleMCPToolCall(w, r, request) + default: + writeMCPError(w, request.ID, -32601, "method not found") + } +} + +func (a *app) handleMCPResourceRead(w http.ResponseWriter, request mcpRequest) { + var params struct { + URI string `json:"uri"` + } + if err := json.Unmarshal(request.Params, ¶ms); err != nil || params.URI == "" { + writeMCPError(w, request.ID, -32602, "uri is required") + return + } + + var resource any + switch params.URI { + case "cms://capabilities": + resource = map[string]any{ + "modes": []string{"theme", "site", "scorm", "site_and_course"}, + "proposalOnly": true, + "requiresCMSApproval": true, + "themeTools": []string{"theme_propose", "theme_validate"}, + "siteTools": []string{"site_propose_structure", "site_upsert_page_proposal", "site_validate"}, + } + case "cms://themes/schema": + resource = themeSchemaResource() + case "cms://sites/schema": + resource = siteSchemaResource() + default: + writeMCPError(w, request.ID, -32002, "resource not found") + return + } + + encoded, err := json.Marshal(resource) + if err != nil { + writeMCPError(w, request.ID, -32603, "failed to encode resource") + return + } + writeMCPResult(w, request.ID, map[string]any{"contents": []map[string]any{ + {"uri": params.URI, "mimeType": "application/json", "text": string(encoded)}, + }}) +} + +func (a *app) handleMCPToolCall(w http.ResponseWriter, r *http.Request, request mcpRequest) { + var params struct { + Name string `json:"name"` + Arguments map[string]any `json:"arguments"` + } + if err := json.Unmarshal(request.Params, ¶ms); err != nil || params.Name == "" { + writeMCPError(w, request.ID, -32602, "tool name is required") + return + } + requiredScope := "cms.authoring.propose" + if params.Name == "authoring_get_status" || params.Name == "authoring_get_review_link" { + requiredScope = "cms.authoring.read" + } + if !a.mcpScopeAllowed(r, requiredScope) { + writeMCPError(w, request.ID, -32001, "insufficient OAuth scope") + return + } + + result, err := a.callExternalAuthoringTool(r, params.Name, params.Arguments) + if err != nil { + writeMCPToolResult(w, request.ID, map[string]any{"error": err.Error()}, true) + return + } + writeMCPToolResult(w, request.ID, result, false) +} + +func (a *app) mcpScopeAllowed(r *http.Request, required string) bool { + if a.cfg.AuthDisabled { + return true + } + claims, _ := r.Context().Value(userClaimsKey).(*accessClaims) + return hasOAuthScope(claims, required) +} + +func writeMCPToolResult(w http.ResponseWriter, id json.RawMessage, result any, isError bool) { + encoded, err := json.Marshal(result) + if err != nil { + writeMCPError(w, id, -32603, "failed to encode tool result") + return + } + writeMCPResult(w, id, map[string]any{ + "content": []map[string]any{{"type": "text", "text": string(encoded)}}, + "structuredContent": result, + "isError": isError, + }) +} + +func externalAuthoringTools() []mcpTool { + return []mcpTool{ + newMCPTool("authoring_start", "Create a proposal-only authoring run. This tool never changes or publishes production content.", map[string]any{ + "brief": map[string]any{"type": "object", "description": "Structured site, theme, or SCORM authoring brief."}, + }, []string{"brief"}), + newMCPTool("authoring_get_status", "Get the status, assumptions, validation summary, and review state for an authoring run.", map[string]any{ + "runId": map[string]any{"type": "string"}, + }, []string{"runId"}), + newMCPTool("theme_propose", "Store a complete semantic theme proposal in an authoring run without applying it.", map[string]any{ + "runId": map[string]any{"type": "string"}, + "idempotencyKey": map[string]any{"type": "string"}, + "theme": map[string]any{"type": "object"}, + }, []string{"runId", "idempotencyKey", "theme"}), + newMCPTool("theme_validate", "Validate the proposed theme tokens and color contrast.", map[string]any{ + "runId": map[string]any{"type": "string"}, + }, []string{"runId"}), + newMCPTool("site_propose_structure", "Store a proposal-only site title and navigation structure without creating CMS pages.", map[string]any{ + "runId": map[string]any{"type": "string"}, + "idempotencyKey": map[string]any{"type": "string"}, + "site": map[string]any{"type": "object"}, + }, []string{"runId", "idempotencyKey", "site"}), + newMCPTool("site_upsert_page_proposal", "Store one complete normalized page tree in a site proposal without changing CMS pages.", map[string]any{ + "runId": map[string]any{"type": "string"}, + "idempotencyKey": map[string]any{"type": "string"}, + "page": map[string]any{"type": "object"}, + }, []string{"runId", "idempotencyKey", "page"}), + newMCPTool("site_validate", "Validate proposed page IDs, slugs, SEO, block trees, and navigation targets.", map[string]any{ + "runId": map[string]any{"type": "string"}, + }, []string{"runId"}), + newMCPTool("authoring_get_review_link", "Return an authenticated CMS review URL for a validated proposal.", map[string]any{ + "runId": map[string]any{"type": "string"}, + }, []string{"runId"}), + newMCPTool("authoring_discard", "Discard an unapplied proposal owned by the current user.", map[string]any{ + "runId": map[string]any{"type": "string"}, + }, []string{"runId"}), + } +} + +func newMCPTool(name, description string, properties map[string]any, required []string) mcpTool { + return mcpTool{ + Name: name, + Description: description, + InputSchema: map[string]any{ + "type": "object", + "properties": properties, + "required": required, + "additionalProperties": false, + }, + } +} + +func writeMCPResult(w http.ResponseWriter, id json.RawMessage, result any) { + writeJSON(w, http.StatusOK, mcpResponse{JSONRPC: "2.0", ID: id, Result: result}) +} + +func writeMCPError(w http.ResponseWriter, id json.RawMessage, code int, message string) { + writeJSON(w, http.StatusOK, mcpResponse{ + JSONRPC: "2.0", + ID: id, + Error: &mcpError{Code: code, Message: message}, + }) +} \ No newline at end of file diff --git a/Backend/cmd/api/middleware.go b/Backend/cmd/api/middleware.go new file mode 100644 index 0000000..e444c52 --- /dev/null +++ b/Backend/cmd/api/middleware.go @@ -0,0 +1,198 @@ +package main + +import ( + "context" + "net/http" + "strings" +) + +type authContextKey string + +const userClaimsKey authContextKey = "userClaims" +const tenantContextKey authContextKey = "tenantContext" + +type accessClaims struct { + Sub string `json:"sub"` + Email string `json:"email"` + PreferredUsername string `json:"preferred_username"` + AuthorizedParty string `json:"azp"` + TenantID string `json:"tenant_id"` + TenantIDAlt string `json:"tenantId"` + OrganizationID string `json:"organization_id"` + OrganizationIDAlt string `json:"organizationId"` + OrganisationID string `json:"organisation_id"` + OrganisationIDAlt string `json:"organisationId"` + OrgID string `json:"org_id"` + OrgIDAlt string `json:"orgId"` + Permissions []string `json:"permissions"` + Scope string `json:"scope"` + Scopes []string `json:"scopes"` + RealmAccess struct { + Roles []string `json:"roles"` + } `json:"realm_access"` +} + +func hasOAuthScope(claims *accessClaims, required string) bool { + if claims == nil { + return false + } + for _, scope := range append(strings.Fields(claims.Scope), claims.Scopes...) { + if scope == required { + return true + } + } + return false +} + +type tenantContext struct { + ID string + BypassScoping bool + RequestedByOps bool +} + +func (a *app) authMiddleware(next http.Handler) http.Handler { + if a.cfg.AuthDisabled { + return next + } + + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if a.oidcVerifier == nil { + writeError(w, http.StatusServiceUnavailable, "oidc verifier not configured") + return + } + + authHeader := r.Header.Get("Authorization") + if authHeader == "" { + writeError(w, http.StatusUnauthorized, "missing authorization header") + return + } + + tokenString := strings.TrimSpace(strings.TrimPrefix(authHeader, "Bearer ")) + if tokenString == "" { + writeError(w, http.StatusUnauthorized, "invalid bearer token") + return + } + + idToken, err := a.oidcVerifier.Verify(r.Context(), tokenString) + if err != nil { + writeError(w, http.StatusUnauthorized, "invalid token") + return + } + + claims := &accessClaims{} + if err := idToken.Claims(claims); err != nil { + writeError(w, http.StatusUnauthorized, "invalid token claims") + return + } + + if expected := strings.TrimSpace(a.cfg.KeycloakAudience); expected != "" { + if !contains(idToken.Audience, expected) && strings.TrimSpace(claims.AuthorizedParty) != expected { + writeError(w, http.StatusUnauthorized, "token client is not authorized for this application") + return + } + } + + tenantID := resolveClaimsTenantID(claims) + isPlatformRole := hasAnyRole(claims, "platform_owner", "platform_admin") + requestedTenantID := strings.TrimSpace(r.Header.Get("X-Tenant-Id")) + if requestedTenantID != "" { + if !isPlatformRole && tenantID != requestedTenantID { + writeError(w, http.StatusForbidden, "tenant override requires platform role") + return + } + tenantID = requestedTenantID + } + + ctx := context.WithValue(r.Context(), userClaimsKey, claims) + ctx = context.WithValue(ctx, tenantContextKey, tenantContext{ + ID: tenantID, + BypassScoping: isPlatformRole, + RequestedByOps: requestedTenantID != "", + }) + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} + +func withCORS(allowedOrigins []string, next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + origin := r.Header.Get("Origin") + if isAllowedOrigin(origin, allowedOrigins) { + w.Header().Set("Access-Control-Allow-Origin", origin) + } else if contains(allowedOrigins, "*") { + w.Header().Set("Access-Control-Allow-Origin", "*") + } + + w.Header().Set("Access-Control-Allow-Methods", "GET,POST,PUT,PATCH,DELETE,OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization") + + if r.Method == http.MethodOptions { + w.WriteHeader(http.StatusNoContent) + return + } + + next.ServeHTTP(w, r) + }) +} + +func isAllowedOrigin(origin string, allowed []string) bool { + if origin == "" { + return false + } + for _, entry := range allowed { + if entry == "*" || entry == origin { + return true + } + } + return false +} + +func contains(values []string, needle string) bool { + for _, value := range values { + if value == needle { + return true + } + } + return false +} + +func hasRole(claims *accessClaims, role string) bool { + for _, candidate := range claims.RealmAccess.Roles { + if candidate == role { + return true + } + } + return false +} + +func resolveClaimsTenantID(claims *accessClaims) string { + if claims == nil { + return "" + } + for _, candidate := range []string{ + claims.TenantID, + claims.TenantIDAlt, + claims.OrganizationID, + claims.OrganizationIDAlt, + claims.OrganisationID, + claims.OrganisationIDAlt, + claims.OrgID, + claims.OrgIDAlt, + } { + if trimmed := strings.TrimSpace(candidate); trimmed != "" { + return trimmed + } + } + return "" +} + +func resolveClaimsUserID(claims *accessClaims) string { + if claims == nil { + return "" + } + for _, candidate := range []string{claims.Sub, claims.PreferredUsername, claims.Email} { + if trimmed := strings.TrimSpace(candidate); trimmed != "" { + return trimmed + } + } + return "" +} diff --git a/Backend/cmd/api/notifications.go b/Backend/cmd/api/notifications.go new file mode 100644 index 0000000..8b034d2 --- /dev/null +++ b/Backend/cmd/api/notifications.go @@ -0,0 +1,1419 @@ +package main + +import ( + "context" + "errors" + "log" + "math" + "net/http" + "strconv" + "strings" + "time" + + "github.com/go-chi/chi/v5" +) + +const ( + notificationStatusQueued = "queued" + notificationStatusSent = "sent" + notificationStatusDeferred = "deferred" + notificationStatusFailed = "failed" + notificationStatusThrottled = "throttled" + + notificationThrottleDefer = "defer" + notificationThrottleDrop = "drop" + notificationThrottleDeadLetter = "dead-letter" +) + +type notificationRequest struct { + Channel string + To string + Subject string + Body string + TemplateID string + Metadata map[string]any +} + +type notificationProviderCapabilities struct { + Email bool `json:"email"` + Push bool `json:"push"` + Templates bool `json:"templates"` + Webhooks bool `json:"webhooks"` + Events bool `json:"events"` +} + +type notificationProviderMetadata struct { + Name string `json:"name"` + Capabilities notificationProviderCapabilities `json:"capabilities"` +} + +type notificationProviderResult struct { + ProviderMessageID string + Status string +} + +type notificationProvider interface { + Send(context.Context, notificationRequest) (notificationProviderResult, error) + Metadata() notificationProviderMetadata +} + +type noopNotificationProvider struct{} + +func (noopNotificationProvider) Send(ctx context.Context, request notificationRequest) (notificationProviderResult, error) { + return notificationProviderResult{ + ProviderMessageID: "noop-" + strings.ReplaceAll(request.To, "@", "_at_"), + Status: notificationStatusQueued, + }, nil +} + +func (noopNotificationProvider) Metadata() notificationProviderMetadata { + return notificationProviderMetadata{ + Name: "noop", + Capabilities: notificationProviderCapabilities{ + Email: true, + }, + } +} + +func (a *app) activeNotificationProvider() notificationProvider { + if a.notificationProvider != nil { + return a.notificationProvider + } + return noopNotificationProvider{} +} + +func (a *app) notificationsAdminMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + claims, _ := r.Context().Value(userClaimsKey).(*accessClaims) + if claims == nil { + writeError(w, http.StatusUnauthorized, "no authenticated user") + return + } + if !hasRole(claims, "admin") { + writeError(w, http.StatusForbidden, "insufficient role for notification management") + return + } + next.ServeHTTP(w, r) + }) +} + +func (a *app) listNotificationTemplates(w http.ResponseWriter, r *http.Request) { + a.listCollectionWithRequest("notification_templates", w, r) +} + +func (a *app) createNotificationTemplate(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 + } + + doc, err := prepareNotificationTemplatePayload(Doc(payload), false) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + + tenantID, enforceTenant, scopeErr := requestTenantScope(r, "notification_templates") + if scopeErr != nil { + writeError(w, http.StatusForbidden, scopeErr.Error()) + return + } + if enforceTenant { + doc["tenantId"] = tenantID + } + + doc, err = a.create("notification_templates", doc) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to create resource") + return + } + writeJSON(w, http.StatusCreated, doc) +} + +func (a *app) getNotificationTemplate(w http.ResponseWriter, r *http.Request) { + a.getCollection("notification_templates", w, r) +} + +func (a *app) patchNotificationTemplate(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + var payload map[string]any + if err := decodeJSON(r, &payload); err != nil { + writeError(w, http.StatusBadRequest, "invalid patch payload") + return + } + + patch, err := prepareNotificationTemplatePayload(Doc(payload), true) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + + tenantID, enforceTenant, scopeErr := requestTenantScope(r, "notification_templates") + if scopeErr != nil { + writeError(w, http.StatusForbidden, scopeErr.Error()) + return + } + if enforceTenant { + delete(patch, "tenantId") + } + doc, err := a.patchWithUnsetScoped("notification_templates", id, 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) deleteNotificationTemplate(w http.ResponseWriter, r *http.Request) { + a.deleteCollection("notification_templates", w, r) +} + +func (a *app) listNotificationPreferences(w http.ResponseWriter, r *http.Request) { + a.listCollectionWithRequest("notification_preferences", w, r) +} + +func (a *app) createNotificationPreference(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 + } + + doc, err := prepareNotificationPreferencePayload(Doc(payload), false) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + + tenantID, enforceTenant, scopeErr := requestTenantScope(r, "notification_preferences") + if scopeErr != nil { + writeError(w, http.StatusForbidden, scopeErr.Error()) + return + } + if enforceTenant { + doc["tenantId"] = tenantID + } + + doc, err = a.create("notification_preferences", doc) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to create resource") + return + } + writeJSON(w, http.StatusCreated, doc) +} + +func (a *app) patchNotificationPreference(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + var payload map[string]any + if err := decodeJSON(r, &payload); err != nil { + writeError(w, http.StatusBadRequest, "invalid patch payload") + return + } + + patch, err := prepareNotificationPreferencePayload(Doc(payload), true) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + + tenantID, enforceTenant, scopeErr := requestTenantScope(r, "notification_preferences") + if scopeErr != nil { + writeError(w, http.StatusForbidden, scopeErr.Error()) + return + } + if enforceTenant { + delete(patch, "tenantId") + } + + doc, err := a.patchWithUnsetScoped("notification_preferences", id, 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) listNotificationDeliveries(w http.ResponseWriter, r *http.Request) { + a.listCollectionWithRequest("notification_deliveries", w, r) +} + +func (a *app) listNotificationQueue(w http.ResponseWriter, r *http.Request) { + a.listCollectionWithRequest("notification_queue", w, r) +} + +func (a *app) listNotificationDeadLetters(w http.ResponseWriter, r *http.Request) { + a.listCollectionWithRequest("notification_dead_letters", w, r) +} + +func (a *app) listNotificationPolicies(w http.ResponseWriter, r *http.Request) { + a.listCollectionWithRequest("notification_rate_limit_policies", w, r) +} + +func (a *app) createNotificationPolicy(w http.ResponseWriter, r *http.Request) { + tenantID, err := enforceTenantScopedMutation(r, "notification_rate_limit_policies") + if err != nil { + writeError(w, http.StatusForbidden, err.Error()) + return + } + var payload map[string]any + if err := decodeJSON(r, &payload); err != nil { + writeError(w, http.StatusBadRequest, "invalid payload") + return + } + policy, err := prepareNotificationPolicyPayload(Doc(payload), false) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + policy["tenantId"] = tenantID + doc, err := a.create("notification_rate_limit_policies", policy) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to create policy") + return + } + claims, _ := claimsFromRequest(r) + a.recordAuditEvent(r.Context(), claims, "notifications.policy.create", "notification_rate_limit_policies", auditResultSuccess, map[string]any{ + "tenantId": tenantID, + "channel": anyToString(doc["channel"]), + }) + writeJSON(w, http.StatusCreated, doc) +} + +func (a *app) patchNotificationPolicy(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + tenantID, err := enforceTenantScopedMutation(r, "notification_rate_limit_policies") + if err != nil { + writeError(w, http.StatusForbidden, err.Error()) + return + } + var payload map[string]any + if err := decodeJSON(r, &payload); err != nil { + writeError(w, http.StatusBadRequest, "invalid payload") + return + } + patch, err := prepareNotificationPolicyPayload(Doc(payload), true) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + delete(patch, "tenantId") + doc, err := a.patchWithUnsetScoped("notification_rate_limit_policies", id, patch, nil, tenantID, true) + if err != nil { + if errors.Is(err, ErrNotFound) { + writeError(w, http.StatusNotFound, "policy not found") + return + } + writeError(w, http.StatusInternalServerError, "failed to patch policy") + return + } + claims, _ := claimsFromRequest(r) + a.recordAuditEvent(r.Context(), claims, "notifications.policy.patch", "notification_rate_limit_policies", auditResultSuccess, map[string]any{ + "tenantId": tenantID, + "policyId": id, + }) + writeJSON(w, http.StatusOK, doc) +} + +func (a *app) deleteNotificationPolicy(w http.ResponseWriter, r *http.Request) { + tenantID, err := enforceTenantScopedMutation(r, "notification_rate_limit_policies") + if err != nil { + writeError(w, http.StatusForbidden, err.Error()) + return + } + id := chi.URLParam(r, "id") + if err := a.deleteByIDScoped("notification_rate_limit_policies", id, tenantID, true); err != nil { + if errors.Is(err, ErrNotFound) { + writeError(w, http.StatusNotFound, "policy not found") + return + } + writeError(w, http.StatusInternalServerError, "failed to delete policy") + return + } + claims, _ := claimsFromRequest(r) + a.recordAuditEvent(r.Context(), claims, "notifications.policy.delete", "notification_rate_limit_policies", auditResultSuccess, map[string]any{ + "tenantId": tenantID, + "policyId": id, + }) + w.WriteHeader(http.StatusNoContent) +} + +func (a *app) listNotificationProviderConfigs(w http.ResponseWriter, r *http.Request) { + tenantID, enforceTenant, scopeErr := requestTenantScope(r, "notification_provider_configs") + if scopeErr != nil { + writeError(w, http.StatusForbidden, scopeErr.Error()) + return + } + filter := Doc{} + if enforceTenant { + filter["tenantId"] = tenantID + } + docs, err := a.list("notification_provider_configs", filter, Sort{{Key: "updatedAt", Value: -1}}) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to list provider configs") + return + } + for _, doc := range docs { + delete(doc, "apiKey") + } + writeJSON(w, http.StatusOK, docs) +} + +func (a *app) createNotificationProviderConfig(w http.ResponseWriter, r *http.Request) { + tenantID, err := enforceTenantScopedMutation(r, "notification_provider_configs") + if err != nil { + writeError(w, http.StatusForbidden, err.Error()) + return + } + var payload map[string]any + if err := decodeJSON(r, &payload); err != nil { + writeError(w, http.StatusBadRequest, "invalid payload") + return + } + doc, err := prepareNotificationProviderConfigPayload(Doc(payload), false) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + doc["tenantId"] = tenantID + doc, err = a.create("notification_provider_configs", doc) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to create provider config") + return + } + delete(doc, "apiKey") + claims, _ := claimsFromRequest(r) + a.recordAuditEvent(r.Context(), claims, "notifications.provider_config.create", "notification_provider_configs", auditResultSuccess, map[string]any{ + "tenantId": tenantID, + "provider": anyToString(doc["provider"]), + }) + writeJSON(w, http.StatusCreated, doc) +} + +func (a *app) patchNotificationProviderConfig(w http.ResponseWriter, r *http.Request) { + tenantID, err := enforceTenantScopedMutation(r, "notification_provider_configs") + if err != nil { + writeError(w, http.StatusForbidden, err.Error()) + return + } + id := chi.URLParam(r, "id") + var payload map[string]any + if err := decodeJSON(r, &payload); err != nil { + writeError(w, http.StatusBadRequest, "invalid payload") + return + } + patch, err := prepareNotificationProviderConfigPayload(Doc(payload), true) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + delete(patch, "tenantId") + doc, err := a.patchWithUnsetScoped("notification_provider_configs", id, patch, nil, tenantID, true) + if err != nil { + if errors.Is(err, ErrNotFound) { + writeError(w, http.StatusNotFound, "provider config not found") + return + } + writeError(w, http.StatusInternalServerError, "failed to patch provider config") + return + } + delete(doc, "apiKey") + claims, _ := claimsFromRequest(r) + a.recordAuditEvent(r.Context(), claims, "notifications.provider_config.patch", "notification_provider_configs", auditResultSuccess, map[string]any{ + "tenantId": tenantID, + "id": id, + }) + writeJSON(w, http.StatusOK, doc) +} + +func (a *app) getNotificationProviderHealth(w http.ResponseWriter, r *http.Request) { + tenantID, _, _ := requestTenantScope(r, "notification_deliveries") + active := a.activeNotificationProvider().Metadata() + configured := []map[string]any{ + { + "name": active.Name, + "capabilities": active.Capabilities, + "healthy": true, + "source": "platform-default", + }, + } + if tenantID != "" { + docs, err := a.list("notification_provider_configs", Doc{"tenantId": tenantID}, Sort{{Key: "updatedAt", Value: -1}}) + if err == nil { + for _, doc := range docs { + providerName := strings.ToLower(strings.TrimSpace(anyToString(doc["provider"]))) + provider := newNotificationProviderFromProfile(notificationProviderProfile{ + Provider: providerName, + APIKey: anyToString(doc["apiKey"]), + FromEmail: anyToString(doc["fromEmail"]), + APIBaseURL: anyToString(doc["apiBaseURL"]), + }) + meta := provider.Metadata() + configured = append(configured, map[string]any{ + "id": doc["id"], + "name": meta.Name, + "capabilities": meta.Capabilities, + "healthy": true, + "source": "tenant", + "enabled": anyToBool(doc["enabled"]), + }) + } + } + } + writeJSON(w, http.StatusOK, map[string]any{ + "providers": configured, + }) +} + +func (a *app) createNotificationDelivery(w http.ResponseWriter, r *http.Request) { + claims, _ := claimsFromRequest(r) + var payload map[string]any + if err := decodeJSON(r, &payload); err != nil { + a.recordAuditEvent(r.Context(), claims, "notifications.delivery.create", "notification_deliveries", auditResultFailed, map[string]any{ + "reason": "invalid_payload", + }) + writeError(w, http.StatusBadRequest, "invalid payload") + return + } + + req, err := prepareNotificationDeliveryRequest(Doc(payload)) + if err != nil { + a.recordAuditEvent(r.Context(), claims, "notifications.delivery.create", "notification_deliveries", auditResultFailed, map[string]any{ + "reason": "validation_error", + "message": err.Error(), + }) + writeError(w, http.StatusBadRequest, err.Error()) + return + } + + tenantID, enforceTenant, scopeErr := requestTenantScope(r, "notification_deliveries") + if scopeErr != nil { + writeError(w, http.StatusForbidden, scopeErr.Error()) + return + } + if enforceTenant { + if req.Metadata == nil { + req.Metadata = map[string]any{} + } + req.Metadata["tenantId"] = tenantID + } + now := time.Now().UnixMilli() + idempotencyKey := strings.TrimSpace(anyToString(payload["idempotencyKey"])) + if idempotencyKey == "" { + idempotencyKey = strings.TrimSpace(anyToString(req.Metadata["idempotencyKey"])) + } + + delivery := Doc{ + "tenantId": tenantID, + "channel": req.Channel, + "to": req.To, + "subject": req.Subject, + "body": req.Body, + "templateId": req.TemplateID, + "metadata": req.Metadata, + "provider": strings.TrimSpace(a.cfg.NotificationProvider), + "status": notificationStatusQueued, + "queuedAt": now, + "idempotencyKey": idempotencyKey, + "compatSyncRoute": true, + } + delivery, err = a.create("notification_deliveries", delivery) + if err != nil { + a.recordAuditEvent(r.Context(), claims, "notifications.delivery.create", "notification_deliveries", auditResultFailed, map[string]any{ + "channel": req.Channel, + "to": req.To, + "reason": "persist_failed", + }) + writeError(w, http.StatusInternalServerError, "failed to create delivery record") + return + } + + queueDoc := Doc{ + "tenantId": tenantID, + "deliveryId": anyToString(delivery["id"]), + "channel": req.Channel, + "to": req.To, + "subject": req.Subject, + "body": req.Body, + "templateId": req.TemplateID, + "metadata": req.Metadata, + "attemptCount": 0, + "nextAttemptAt": now, + "status": notificationStatusQueued, + "idempotencyKey": idempotencyKey, + "providerHint": strings.TrimSpace(anyToString(payload["provider"])), + "fallbackProvider": strings.TrimSpace(anyToString(payload["fallbackProvider"])), + } + queueDoc, err = a.create("notification_queue", queueDoc) + if err != nil { + _, _ = a.patch("notification_deliveries", anyToString(delivery["id"]), Doc{ + "status": notificationStatusFailed, + "lastError": "queue_insert_failed", + "failedAt": time.Now().UnixMilli(), + "updatedAt": time.Now().UnixMilli(), + }) + writeError(w, http.StatusInternalServerError, "failed to enqueue delivery") + return + } + + if shouldProcessSynchronously(payload, r) { + if _, err := a.processNotificationQueueItem(r.Context(), anyToString(queueDoc["id"])); err != nil { + log.Printf("sync notification queue processing failed queueId=%s err=%v", anyToString(queueDoc["id"]), err) + } + updated, getErr := a.getByIDScoped("notification_deliveries", anyToString(delivery["id"]), tenantID, enforceTenant) + if getErr == nil { + delivery = updated + } + } + + a.recordAuditEvent(r.Context(), claims, "notifications.delivery.create", "notification_deliveries", auditResultSuccess, map[string]any{ + "tenantId": tenantID, + "channel": req.Channel, + "templateId": req.TemplateID, + "to": req.To, + "status": anyToString(delivery["status"]), + }) + writeJSON(w, http.StatusCreated, delivery) +} + +func (a *app) processNotificationQueue(w http.ResponseWriter, r *http.Request) { + limit := 20 + if raw := strings.TrimSpace(r.URL.Query().Get("limit")); raw != "" { + if parsed, err := strconv.Atoi(raw); err == nil && parsed > 0 && parsed <= 100 { + limit = parsed + } + } + tenantID, enforceTenant, scopeErr := requestTenantScope(r, "notification_queue") + if scopeErr != nil { + writeError(w, http.StatusForbidden, scopeErr.Error()) + return + } + col, err := a.resolveCollection("notification_queue") + if err != nil { + writeError(w, http.StatusInternalServerError, "queue unavailable") + return + } + now := time.Now().UnixMilli() + filter := Doc{"nextAttemptAt": Doc{"$lte": now}} + if enforceTenant { + filter["tenantId"] = tenantID + } + ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) + defer cancel() + cursor, err := col.Find(ctx, filter, NewFindOptions().SetSort(Sort{{Key: "nextAttemptAt", Value: 1}}).SetLimit(int64(limit))) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to load queue") + return + } + defer cursor.Close(ctx) + + processed := 0 + sent := 0 + deferred := 0 + failed := 0 + throttled := 0 + for cursor.Next(ctx) { + var doc Doc + if err := cursor.Decode(&doc); err != nil { + continue + } + normalizeDocument(doc) + status, err := a.processNotificationQueueItem(r.Context(), anyToString(doc["id"])) + if err != nil { + log.Printf("notification worker failed queueId=%s err=%v", anyToString(doc["id"]), err) + continue + } + processed++ + switch status { + case notificationStatusSent: + sent++ + case notificationStatusDeferred: + deferred++ + case notificationStatusFailed: + failed++ + case notificationStatusThrottled: + throttled++ + } + } + writeJSON(w, http.StatusOK, map[string]any{ + "processed": processed, + "sent": sent, + "deferred": deferred, + "failed": failed, + "throttled": throttled, + "inspected": limit, + "processedAt": time.Now().UnixMilli(), + }) +} + +func (a *app) replayNotificationDeadLetter(w http.ResponseWriter, r *http.Request) { + tenantID, err := enforceTenantScopedMutation(r, "notification_dead_letters") + if err != nil { + writeError(w, http.StatusForbidden, err.Error()) + return + } + id := chi.URLParam(r, "id") + deadLetter, err := a.getByIDScoped("notification_dead_letters", id, tenantID, true) + if err != nil { + if errors.Is(err, ErrNotFound) { + writeError(w, http.StatusNotFound, "dead letter not found") + return + } + writeError(w, http.StatusInternalServerError, "failed to load dead letter") + return + } + queueDoc := Doc{ + "tenantId": tenantID, + "deliveryId": anyToString(deadLetter["deliveryId"]), + "channel": anyToString(deadLetter["channel"]), + "to": anyToString(deadLetter["to"]), + "subject": anyToString(deadLetter["subject"]), + "body": anyToString(deadLetter["body"]), + "templateId": anyToString(deadLetter["templateId"]), + "metadata": copyAnyMap(deadLetter["metadata"]), + "attemptCount": 0, + "nextAttemptAt": time.Now().UnixMilli(), + "status": notificationStatusQueued, + "idempotencyKey": anyToString(deadLetter["idempotencyKey"]), + "providerHint": anyToString(deadLetter["providerHint"]), + "fallbackProvider": anyToString(deadLetter["fallbackProvider"]), + "replayedFrom": id, + } + queueDoc, err = a.create("notification_queue", queueDoc) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to enqueue replay") + return + } + if deliveryID := anyToString(deadLetter["deliveryId"]); deliveryID != "" { + _, _ = a.patchWithUnsetScoped("notification_deliveries", deliveryID, Doc{ + "status": notificationStatusQueued, + "queuedAt": time.Now().UnixMilli(), + }, nil, tenantID, true) + } + _, _ = a.patchWithUnsetScoped("notification_dead_letters", id, Doc{ + "replayedAt": time.Now().UnixMilli(), + "replayId": anyToString(queueDoc["id"]), + }, nil, tenantID, true) + claims, _ := claimsFromRequest(r) + a.recordAuditEvent(r.Context(), claims, "notifications.dead_letter.replay", "notification_dead_letters", auditResultSuccess, map[string]any{ + "tenantId": tenantID, + "deadLetterId": id, + "queueId": anyToString(queueDoc["id"]), + }) + writeJSON(w, http.StatusAccepted, queueDoc) +} + +func (a *app) processNotificationQueueItem(ctx context.Context, queueID string) (string, error) { + queueDoc, err := a.getByID("notification_queue", queueID) + if err != nil { + return "", err + } + deliveryID := anyToString(queueDoc["deliveryId"]) + tenantID := strings.TrimSpace(anyToString(queueDoc["tenantId"])) + delivery, err := a.getByIDScoped("notification_deliveries", deliveryID, tenantID, tenantID != "") + if err == nil && anyToString(delivery["status"]) == notificationStatusSent { + _ = a.deleteByID("notification_queue", queueID) + return notificationStatusSent, nil + } + + request := notificationRequest{ + Channel: strings.ToLower(strings.TrimSpace(anyToString(queueDoc["channel"]))), + To: strings.TrimSpace(anyToString(queueDoc["to"])), + Subject: anyToString(queueDoc["subject"]), + Body: anyToString(queueDoc["body"]), + TemplateID: anyToString(queueDoc["templateId"]), + Metadata: copyAnyMap(queueDoc["metadata"]), + } + policy, err := a.resolveTenantNotificationPolicy(ctx, tenantID, request.Channel) + if err != nil { + return "", err + } + throttled, throttleReason, throttleAction, err := a.isNotificationThrottled(ctx, tenantID, request, policy) + if err != nil { + return "", err + } + if throttled { + status, applyErr := a.applyNotificationThrottle(queueDoc, deliveryID, throttleReason, throttleAction, policy) + return status, applyErr + } + + provider := a.resolveNotificationProvider(ctx, tenantID, anyToString(queueDoc["providerHint"]), anyToString(queueDoc["fallbackProvider"])) + result, sendErr := provider.Send(ctx, request) + attemptNumber := anyToInt(queueDoc["attemptCount"]) + 1 + if sendErr != nil { + _ = a.recordDeliveryAttempt(tenantID, deliveryID, queueID, attemptNumber, notificationStatusFailed, sendErr.Error(), "") + if attemptNumber > policy.MaxRetries { + if err := a.moveQueueToDeadLetter(queueDoc, deliveryID, sendErr.Error(), notificationStatusFailed); err != nil { + return "", err + } + return notificationStatusFailed, nil + } + backoff := computeNotificationBackoffSeconds(policy, attemptNumber) + _, _ = a.patch("notification_queue", queueID, Doc{ + "attemptCount": attemptNumber, + "status": notificationStatusDeferred, + "lastError": sendErr.Error(), + "nextAttemptAt": time.Now().Add(time.Duration(backoff) * time.Second).UnixMilli(), + }) + _, _ = a.patch("notification_deliveries", deliveryID, Doc{ + "status": notificationStatusDeferred, + "lastError": sendErr.Error(), + "retryCount": attemptNumber, + "provider": provider.Metadata().Name, + "deferredAt": time.Now().UnixMilli(), + "updatedAt": time.Now().UnixMilli(), + }) + return notificationStatusDeferred, nil + } + + if result.Status == "" { + result.Status = notificationStatusSent + } + _ = a.recordDeliveryAttempt(tenantID, deliveryID, queueID, attemptNumber, notificationStatusSent, "", result.ProviderMessageID) + _, _ = a.patch("notification_deliveries", deliveryID, Doc{ + "status": notificationStatusSent, + "provider": provider.Metadata().Name, + "providerMessageId": result.ProviderMessageID, + "sentAt": time.Now().UnixMilli(), + "retryCount": anyToInt(queueDoc["attemptCount"]), + }) + _ = a.deleteByID("notification_queue", queueID) + return notificationStatusSent, nil +} + +func (a *app) resolveNotificationProvider(ctx context.Context, tenantID, requestedProvider, fallbackProvider string) notificationProvider { + primary := strings.TrimSpace(strings.ToLower(requestedProvider)) + if primary == "" { + primary = strings.TrimSpace(strings.ToLower(a.cfg.NotificationProvider)) + } + fallback := strings.TrimSpace(strings.ToLower(fallbackProvider)) + if fallback == "" { + fallback = "noop" + } + provider := a.resolveNotificationProviderProfile(ctx, tenantID, primary) + if provider.Metadata().Name != "noop" { + return provider + } + return a.resolveNotificationProviderProfile(ctx, tenantID, fallback) +} + +func (a *app) resolveNotificationProviderProfile(ctx context.Context, tenantID, providerName string) notificationProvider { + profile := notificationProviderProfile{ + Provider: providerName, + APIKey: a.cfg.SendGridAPIKey, + FromEmail: a.cfg.SendGridFromEmail, + APIBaseURL: a.cfg.SendGridAPIBaseURL, + } + if strings.TrimSpace(tenantID) != "" { + configDoc, err := a.findActiveNotificationProviderConfig(ctx, tenantID, providerName) + if err == nil && configDoc != nil { + if apiKey := anyToString(configDoc["apiKey"]); apiKey != "" { + profile.APIKey = apiKey + } + if fromEmail := anyToString(configDoc["fromEmail"]); fromEmail != "" { + profile.FromEmail = fromEmail + } + if apiBaseURL := anyToString(configDoc["apiBaseURL"]); apiBaseURL != "" { + profile.APIBaseURL = apiBaseURL + } + } + } + return newNotificationProviderFromProfile(profile) +} + +func (a *app) findActiveNotificationProviderConfig(ctx context.Context, tenantID, providerName string) (Doc, error) { + col, err := a.resolveCollection("notification_provider_configs") + if err != nil { + return nil, err + } + filter := Doc{ + "tenantId": tenantID, + "provider": strings.TrimSpace(strings.ToLower(providerName)), + "enabled": Doc{"$ne": false}, + } + var doc Doc + ctxWithTimeout, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + if err := col.FindOne(ctxWithTimeout, filter, NewFindOneOptions().SetSort(Sort{{Key: "updatedAt", Value: -1}})).Decode(&doc); err != nil { + return nil, err + } + normalizeDocument(doc) + return doc, nil +} + +type notificationPolicy struct { + Channel string + ThroughputPerMinute int + Burst int + MaxRetries int + BackoffSeconds int + MaxBackoffSeconds int + ThrottledAction string + DeferredSeconds int + PerTemplatePerHour int + PerRecipientPerHour int + QuietHoursStartUTC int + QuietHoursEndUTC int + ChannelEnabled bool + FallbackProviderName string +} + +func (a *app) resolveTenantNotificationPolicy(ctx context.Context, tenantID, channel string) (notificationPolicy, error) { + policy := defaultNotificationPolicy(channel) + if strings.TrimSpace(tenantID) == "" { + return policy, nil + } + col, err := a.resolveCollection("notification_rate_limit_policies") + if err != nil { + return policy, err + } + filter := Doc{ + "tenantId": tenantID, + "channel": channel, + } + var doc Doc + ctxWithTimeout, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + if err := col.FindOne(ctxWithTimeout, filter, NewFindOneOptions().SetSort(Sort{{Key: "updatedAt", Value: -1}})).Decode(&doc); err != nil { + if errors.Is(err, ErrNotFound) { + return policy, nil + } + return policy, err + } + normalizeDocument(doc) + policy.ThroughputPerMinute = defaultIfZero(anyToInt(doc["throughputPerMinute"]), policy.ThroughputPerMinute) + policy.Burst = defaultIfZero(anyToInt(doc["burst"]), policy.Burst) + policy.MaxRetries = defaultIfNegative(anyToInt(doc["maxRetries"]), policy.MaxRetries) + policy.BackoffSeconds = defaultIfZero(anyToInt(doc["backoffSeconds"]), policy.BackoffSeconds) + policy.MaxBackoffSeconds = defaultIfZero(anyToInt(doc["maxBackoffSeconds"]), policy.MaxBackoffSeconds) + policy.DeferredSeconds = defaultIfZero(anyToInt(doc["deferredSeconds"]), policy.DeferredSeconds) + policy.PerTemplatePerHour = defaultIfNegative(anyToInt(doc["perTemplatePerHour"]), policy.PerTemplatePerHour) + policy.PerRecipientPerHour = defaultIfNegative(anyToInt(doc["perRecipientPerHour"]), policy.PerRecipientPerHour) + policy.QuietHoursStartUTC = normalizeHour(anyToInt(doc["quietHoursStartUtc"])) + policy.QuietHoursEndUTC = normalizeHour(anyToInt(doc["quietHoursEndUtc"])) + policy.ChannelEnabled = true + if _, has := doc["channelEnabled"]; has { + policy.ChannelEnabled = anyToBool(doc["channelEnabled"]) + } + if throttle := strings.TrimSpace(strings.ToLower(anyToString(doc["throttledAction"]))); throttle != "" { + policy.ThrottledAction = throttle + } + if fallback := strings.TrimSpace(strings.ToLower(anyToString(doc["fallbackProvider"]))); fallback != "" { + policy.FallbackProviderName = fallback + } + return policy, nil +} + +func defaultNotificationPolicy(channel string) notificationPolicy { + return notificationPolicy{ + Channel: strings.TrimSpace(strings.ToLower(channel)), + ThroughputPerMinute: 60, + Burst: 20, + MaxRetries: 3, + BackoffSeconds: 30, + MaxBackoffSeconds: 600, + ThrottledAction: notificationThrottleDefer, + DeferredSeconds: 60, + PerTemplatePerHour: 240, + PerRecipientPerHour: 60, + QuietHoursStartUTC: -1, + QuietHoursEndUTC: -1, + ChannelEnabled: true, + FallbackProviderName: "noop", + } +} + +func (a *app) isNotificationThrottled(ctx context.Context, tenantID string, request notificationRequest, policy notificationPolicy) (bool, string, string, error) { + if !policy.ChannelEnabled { + return true, "channel_disabled", notificationThrottleDrop, nil + } + if inQuietHours(policy, time.Now().UTC()) { + return true, "quiet_hours", notificationThrottleDefer, nil + } + if tenantID == "" { + return false, "", "", nil + } + now := time.Now() + if policy.ThroughputPerMinute > 0 { + count, err := a.countCollection(ctx, "notification_delivery_attempts", Doc{ + "tenantId": tenantID, + "channel": request.Channel, + "createdAt": Doc{"$gte": now.Add(-1 * time.Minute).UnixMilli()}, + "attemptStatus": Doc{"$in": []string{notificationStatusSent, notificationStatusFailed}}, + }) + if err != nil { + return false, "", "", err + } + if count >= int64(policy.ThroughputPerMinute+policy.Burst) { + return true, "tenant_channel_throughput", policy.ThrottledAction, nil + } + } + if policy.PerTemplatePerHour > 0 && request.TemplateID != "" { + count, err := a.countCollection(ctx, "notification_deliveries", Doc{ + "tenantId": tenantID, + "templateId": request.TemplateID, + "queuedAt": Doc{"$gte": now.Add(-1 * time.Hour).UnixMilli()}, + }) + if err != nil { + return false, "", "", err + } + if count >= int64(policy.PerTemplatePerHour) { + return true, "template_safeguard", policy.ThrottledAction, nil + } + } + if policy.PerRecipientPerHour > 0 && request.To != "" { + count, err := a.countCollection(ctx, "notification_deliveries", Doc{ + "tenantId": tenantID, + "to": request.To, + "queuedAt": Doc{"$gte": now.Add(-1 * time.Hour).UnixMilli()}, + }) + if err != nil { + return false, "", "", err + } + if count >= int64(policy.PerRecipientPerHour) { + return true, "recipient_safeguard", policy.ThrottledAction, nil + } + } + return false, "", "", nil +} + +func (a *app) applyNotificationThrottle(queueDoc Doc, deliveryID, reason, action string, policy notificationPolicy) (string, error) { + queueID := anyToString(queueDoc["id"]) + now := time.Now().UnixMilli() + normalizedAction := strings.TrimSpace(strings.ToLower(action)) + if normalizedAction == "" { + normalizedAction = notificationThrottleDefer + } + switch normalizedAction { + case notificationThrottleDrop: + _, _ = a.patch("notification_deliveries", deliveryID, Doc{ + "status": notificationStatusThrottled, + "throttleReason": reason, + "throttledAt": now, + }) + _ = a.deleteByID("notification_queue", queueID) + return notificationStatusThrottled, nil + case notificationThrottleDeadLetter: + if err := a.moveQueueToDeadLetter(queueDoc, deliveryID, reason, notificationStatusThrottled); err != nil { + return "", err + } + return notificationStatusThrottled, nil + default: + nextAttemptAt := time.Now().Add(time.Duration(policy.DeferredSeconds) * time.Second).UnixMilli() + _, _ = a.patch("notification_queue", queueID, Doc{ + "status": notificationStatusDeferred, + "throttleReason": reason, + "nextAttemptAt": nextAttemptAt, + }) + _, _ = a.patch("notification_deliveries", deliveryID, Doc{ + "status": notificationStatusDeferred, + "throttleReason": reason, + "deferredAt": now, + }) + return notificationStatusDeferred, nil + } +} + +func (a *app) moveQueueToDeadLetter(queueDoc Doc, deliveryID, reason, status string) error { + now := time.Now().UnixMilli() + deadLetter := Doc{ + "tenantId": anyToString(queueDoc["tenantId"]), + "deliveryId": deliveryID, + "queueId": anyToString(queueDoc["id"]), + "channel": anyToString(queueDoc["channel"]), + "to": anyToString(queueDoc["to"]), + "subject": anyToString(queueDoc["subject"]), + "body": anyToString(queueDoc["body"]), + "templateId": anyToString(queueDoc["templateId"]), + "metadata": copyAnyMap(queueDoc["metadata"]), + "idempotencyKey": anyToString(queueDoc["idempotencyKey"]), + "providerHint": anyToString(queueDoc["providerHint"]), + "fallbackProvider": anyToString(queueDoc["fallbackProvider"]), + "attemptCount": anyToInt(queueDoc["attemptCount"]), + "reason": reason, + "movedAt": now, + } + if _, err := a.create("notification_dead_letters", deadLetter); err != nil { + return err + } + if deliveryID != "" { + _, _ = a.patch("notification_deliveries", deliveryID, Doc{ + "status": status, + "lastError": reason, + "failedAt": now, + "throttledAt": now, + }) + } + return a.deleteByID("notification_queue", anyToString(queueDoc["id"])) +} + +func (a *app) recordDeliveryAttempt(tenantID, deliveryID, queueID string, attempt int, attemptStatus, errorMessage, providerMessageID string) error { + _, err := a.create("notification_delivery_attempts", Doc{ + "tenantId": tenantID, + "deliveryId": deliveryID, + "queueId": queueID, + "attempt": attempt, + "attemptStatus": attemptStatus, + "errorMessage": errorMessage, + "providerMessageId": providerMessageID, + "createdAt": time.Now().UnixMilli(), + }) + return err +} + +func (a *app) countCollection(ctx context.Context, name string, filter Doc) (int64, error) { + col, err := a.resolveCollection(name) + if err != nil { + return 0, err + } + ctxWithTimeout, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + return col.CountDocuments(ctxWithTimeout, filter) +} + +func enforceTenantScopedMutation(r *http.Request, collection string) (string, error) { + tenantID, enforceTenant, err := requestTenantScope(r, collection) + if err != nil { + return "", err + } + if !enforceTenant || strings.TrimSpace(tenantID) == "" { + return "", errors.New("tenant override required for mutation") + } + return tenantID, nil +} + +func shouldProcessSynchronously(payload map[string]any, r *http.Request) bool { + if rawMode := strings.TrimSpace(strings.ToLower(r.URL.Query().Get("mode"))); rawMode != "" { + return rawMode != "async" + } + if asyncVal, has := payload["async"]; has && anyToBool(asyncVal) { + return false + } + if syncVal, has := payload["sync"]; has { + return anyToBool(syncVal) + } + return true +} + +func computeNotificationBackoffSeconds(policy notificationPolicy, attempt int) int { + if attempt < 1 { + attempt = 1 + } + base := policy.BackoffSeconds + if base <= 0 { + base = 1 + } + backoff := int(float64(base) * math.Pow(2, float64(attempt-1))) + if policy.MaxBackoffSeconds > 0 && backoff > policy.MaxBackoffSeconds { + return policy.MaxBackoffSeconds + } + return backoff +} + +func inQuietHours(policy notificationPolicy, now time.Time) bool { + start := normalizeHour(policy.QuietHoursStartUTC) + end := normalizeHour(policy.QuietHoursEndUTC) + if start < 0 || end < 0 || start == end { + return false + } + hour := now.Hour() + if start < end { + return hour >= start && hour < end + } + return hour >= start || hour < end +} + +func normalizeHour(value int) int { + if value < 0 || value > 23 { + return -1 + } + return value +} + +func copyAnyMap(raw any) map[string]any { + typed, ok := raw.(map[string]any) + if !ok { + if docTyped, docOK := raw.(Doc); docOK { + typed = map[string]any(docTyped) + } else { + return map[string]any{} + } + } + out := make(map[string]any, len(typed)) + for key, value := range typed { + out[key] = value + } + return out +} + +func anyToBool(value any) bool { + switch typed := value.(type) { + case bool: + return typed + case string: + trimmed := strings.TrimSpace(strings.ToLower(typed)) + return trimmed == "1" || trimmed == "true" || trimmed == "yes" || trimmed == "on" + case float64: + return typed != 0 + case int: + return typed != 0 + default: + return false + } +} + +func anyToInt(value any) int { + switch typed := value.(type) { + case int: + return typed + case int32: + return int(typed) + case int64: + return int(typed) + case float64: + return int(typed) + case string: + parsed, err := strconv.Atoi(strings.TrimSpace(typed)) + if err != nil { + return 0 + } + return parsed + default: + return 0 + } +} + +func defaultIfZero(value, fallback int) int { + if value == 0 { + return fallback + } + return value +} + +func defaultIfNegative(value, fallback int) int { + if value < 0 { + return fallback + } + return value +} + +func prepareNotificationTemplatePayload(payload Doc, partial bool) (Doc, error) { + name, hasName, err := getOptionalString(payload, "name") + if err != nil { + return nil, errors.New("name must be a string") + } + if !partial && (!hasName || name == "") { + return nil, errors.New("name is required") + } + if hasName { + payload["name"] = name + } + + channel, hasChannel, err := getOptionalString(payload, "channel") + if err != nil { + return nil, errors.New("channel must be a string") + } + if !partial && !hasChannel { + channel = "email" + hasChannel = true + } + if hasChannel { + channel = strings.ToLower(strings.TrimSpace(channel)) + if channel == "" { + channel = "email" + } + payload["channel"] = channel + } + + for _, field := range []string{"subject", "body"} { + value, has, err := getOptionalString(payload, field) + if err != nil { + return nil, errors.New(field + " must be a string") + } + if has { + payload[field] = value + } + } + + enabledRaw, hasEnabled := payload["enabled"] + if !hasEnabled && !partial { + payload["enabled"] = true + } else if hasEnabled { + enabled, ok := enabledRaw.(bool) + if !ok { + return nil, errors.New("enabled must be a boolean") + } + payload["enabled"] = enabled + } + + return payload, nil +} + +func prepareNotificationPreferencePayload(payload Doc, partial bool) (Doc, error) { + userID, hasUserID, err := getOptionalString(payload, "userId") + if err != nil { + return nil, errors.New("userId must be a string") + } + if !partial && (!hasUserID || userID == "") { + return nil, errors.New("userId is required") + } + if hasUserID { + payload["userId"] = userID + } + + for _, field := range []string{"email", "inApp", "sms", "push"} { + value, has := payload[field] + if !has { + if !partial { + payload[field] = field != "sms" + } + continue + } + boolean, ok := value.(bool) + if !ok { + return nil, errors.New(field + " must be a boolean") + } + payload[field] = boolean + } + + return payload, nil +} + +func prepareNotificationPolicyPayload(payload Doc, partial bool) (Doc, error) { + channel, hasChannel, err := getOptionalString(payload, "channel") + if err != nil { + return nil, errors.New("channel must be a string") + } + if !partial && strings.TrimSpace(channel) == "" && !hasChannel { + channel = "email" + hasChannel = true + } + if hasChannel { + channel = strings.TrimSpace(strings.ToLower(channel)) + if channel == "" { + channel = "email" + } + payload["channel"] = channel + } + for _, field := range []string{ + "throughputPerMinute", "burst", "maxRetries", "backoffSeconds", "maxBackoffSeconds", + "deferredSeconds", "perTemplatePerHour", "perRecipientPerHour", "quietHoursStartUtc", "quietHoursEndUtc", + } { + raw, has := payload[field] + if !has { + continue + } + payload[field] = anyToInt(raw) + } + if raw, has := payload["channelEnabled"]; has { + payload["channelEnabled"] = anyToBool(raw) + } + if raw, has := payload["throttledAction"]; has { + action := strings.TrimSpace(strings.ToLower(anyToString(raw))) + switch action { + case notificationThrottleDefer, notificationThrottleDrop, notificationThrottleDeadLetter: + payload["throttledAction"] = action + default: + return nil, errors.New("throttledAction must be one of defer, drop, dead-letter") + } + } + if raw, has := payload["fallbackProvider"]; has { + payload["fallbackProvider"] = strings.TrimSpace(strings.ToLower(anyToString(raw))) + } + return payload, nil +} + +func prepareNotificationProviderConfigPayload(payload Doc, partial bool) (Doc, error) { + provider, hasProvider, err := getOptionalString(payload, "provider") + if err != nil { + return nil, errors.New("provider must be a string") + } + if !partial && (!hasProvider || provider == "") { + return nil, errors.New("provider is required") + } + if hasProvider { + payload["provider"] = strings.TrimSpace(strings.ToLower(provider)) + } + for _, field := range []string{"apiKey", "fromEmail", "apiBaseURL", "credentialMode"} { + value, has, err := getOptionalString(payload, field) + if err != nil { + return nil, errors.New(field + " must be a string") + } + if has { + payload[field] = value + } + } + if raw, has := payload["enabled"]; has { + payload["enabled"] = anyToBool(raw) + } else if !partial { + payload["enabled"] = true + } + return payload, nil +} + +func prepareNotificationDeliveryRequest(payload Doc) (notificationRequest, error) { + to, hasTo, err := getOptionalString(payload, "to") + if err != nil { + return notificationRequest{}, errors.New("to must be a string") + } + if !hasTo || to == "" { + return notificationRequest{}, errors.New("to is required") + } + + channel, _, err := getOptionalString(payload, "channel") + if err != nil { + return notificationRequest{}, errors.New("channel must be a string") + } + if channel == "" { + channel = "email" + } + + subject, _, err := getOptionalString(payload, "subject") + if err != nil { + return notificationRequest{}, errors.New("subject must be a string") + } + body, _, err := getOptionalString(payload, "body") + if err != nil { + return notificationRequest{}, errors.New("body must be a string") + } + templateID, _, err := getOptionalString(payload, "templateId") + if err != nil { + return notificationRequest{}, errors.New("templateId must be a string") + } + + if subject == "" && body == "" && templateID == "" { + return notificationRequest{}, errors.New("subject/body or templateId is required") + } + + metadata := map[string]any{} + if metadataRaw, has := payload["metadata"]; has { + typed, ok := metadataRaw.(map[string]any) + if !ok { + if docTyped, docOK := metadataRaw.(Doc); docOK { + typed = map[string]any(docTyped) + } else { + return notificationRequest{}, errors.New("metadata must be an object") + } + } + metadata = typed + } + + return notificationRequest{ + Channel: strings.ToLower(strings.TrimSpace(channel)), + To: to, + Subject: subject, + Body: body, + TemplateID: templateID, + Metadata: metadata, + }, nil +} diff --git a/Backend/cmd/api/notifications_sendgrid.go b/Backend/cmd/api/notifications_sendgrid.go new file mode 100644 index 0000000..95f3beb --- /dev/null +++ b/Backend/cmd/api/notifications_sendgrid.go @@ -0,0 +1,168 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "log" + "net/http" + "strings" +) + +type notificationHTTPClient interface { + Do(req *http.Request) (*http.Response, error) +} + +type notificationProviderProfile struct { + Provider string + APIKey string + FromEmail string + APIBaseURL string +} + +type sendGridNotificationProvider struct { + apiKey string + fromEmail string + apiBaseURL string + httpClient notificationHTTPClient +} + +type sendGridEmailAddress struct { + Email string `json:"email"` +} + +type sendGridPersonalization struct { + To []sendGridEmailAddress `json:"to"` + DynamicTemplateData map[string]any `json:"dynamic_template_data,omitempty"` +} + +type sendGridContent struct { + Type string `json:"type"` + Value string `json:"value"` +} + +type sendGridMailPayload struct { + Personalizations []sendGridPersonalization `json:"personalizations"` + From sendGridEmailAddress `json:"from"` + Subject string `json:"subject,omitempty"` + Content []sendGridContent `json:"content,omitempty"` + TemplateID string `json:"template_id,omitempty"` +} + +func newNotificationProvider(cfg config) notificationProvider { + return newNotificationProviderFromProfile(notificationProviderProfile{ + Provider: cfg.NotificationProvider, + APIKey: cfg.SendGridAPIKey, + FromEmail: cfg.SendGridFromEmail, + APIBaseURL: cfg.SendGridAPIBaseURL, + }) +} + +func newNotificationProviderFromProfile(profile notificationProviderProfile) notificationProvider { + provider := strings.TrimSpace(strings.ToLower(profile.Provider)) + if provider == "" || provider == "noop" { + return noopNotificationProvider{} + } + + if provider != "sendgrid" { + log.Printf("unknown NOTIFICATION_PROVIDER=%q; falling back to noop", profile.Provider) + return noopNotificationProvider{} + } + + apiKey := strings.TrimSpace(profile.APIKey) + fromEmail := strings.TrimSpace(profile.FromEmail) + if apiKey == "" || fromEmail == "" { + log.Printf("sendgrid provider selected but missing SENDGRID_API_KEY or SENDGRID_FROM_EMAIL; falling back to noop") + return noopNotificationProvider{} + } + + apiBaseURL := strings.TrimSpace(profile.APIBaseURL) + if apiBaseURL == "" { + apiBaseURL = "https://api.sendgrid.com" + } + + return sendGridNotificationProvider{ + apiKey: apiKey, + fromEmail: fromEmail, + apiBaseURL: strings.TrimRight(apiBaseURL, "/"), + httpClient: http.DefaultClient, + } +} + +func (s sendGridNotificationProvider) Metadata() notificationProviderMetadata { + return notificationProviderMetadata{ + Name: "sendgrid", + Capabilities: notificationProviderCapabilities{ + Email: true, + Templates: true, + Webhooks: true, + Events: true, + }, + } +} + +func (s sendGridNotificationProvider) Send(ctx context.Context, request notificationRequest) (notificationProviderResult, error) { + if strings.TrimSpace(strings.ToLower(request.Channel)) != "email" { + return notificationProviderResult{}, errors.New("sendgrid provider supports email channel only") + } + if strings.TrimSpace(request.To) == "" { + return notificationProviderResult{}, errors.New("notification destination is required") + } + + payload := sendGridMailPayload{ + Personalizations: []sendGridPersonalization{ + { + To: []sendGridEmailAddress{{Email: strings.TrimSpace(request.To)}}, + DynamicTemplateData: request.Metadata, + }, + }, + From: sendGridEmailAddress{Email: s.fromEmail}, + Subject: strings.TrimSpace(request.Subject), + TemplateID: strings.TrimSpace(request.TemplateID), + } + if strings.TrimSpace(request.Body) != "" { + payload.Content = []sendGridContent{{Type: "text/plain", Value: request.Body}} + } + + body, err := json.Marshal(payload) + if err != nil { + return notificationProviderResult{}, fmt.Errorf("marshal sendgrid payload: %w", err) + } + + endpoint := strings.TrimRight(s.apiBaseURL, "/") + "/v3/mail/send" + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body)) + if err != nil { + return notificationProviderResult{}, fmt.Errorf("create sendgrid request: %w", err) + } + req.Header.Set("Authorization", "Bearer "+s.apiKey) + req.Header.Set("Content-Type", "application/json") + + client := s.httpClient + if client == nil { + client = http.DefaultClient + } + + res, err := client.Do(req) + if err != nil { + return notificationProviderResult{}, fmt.Errorf("sendgrid request failed: %w", err) + } + defer res.Body.Close() + + if res.StatusCode < 200 || res.StatusCode > 299 { + raw, _ := io.ReadAll(io.LimitReader(res.Body, 8192)) + return notificationProviderResult{}, fmt.Errorf("sendgrid send failed: status=%d body=%s", res.StatusCode, strings.TrimSpace(string(raw))) + } + + messageID := strings.TrimSpace(res.Header.Get("X-Message-Id")) + if messageID == "" { + messageID = "sendgrid-accepted" + } + + return notificationProviderResult{ + ProviderMessageID: messageID, + Status: "queued", + }, nil +} diff --git a/Backend/cmd/api/pages.go b/Backend/cmd/api/pages.go new file mode 100644 index 0000000..fe11f2b --- /dev/null +++ b/Backend/cmd/api/pages.go @@ -0,0 +1,489 @@ +package main + +import ( + "context" + "errors" + "fmt" + "net/http" + "net/url" + "regexp" + "strings" + "time" + + "github.com/go-chi/chi/v5" +) + +var slugPattern = regexp.MustCompile(`^[a-z0-9]+(?:-[a-z0-9]+)*$`) + +func (a *app) createPage(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 + } + + doc, err := a.preparePagePayload(Doc(payload), "", false) + if err != nil { + a.writePageValidationError(w, err) + return + } + + doc, err = a.create("pages", doc) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to create resource") + return + } + writeJSON(w, http.StatusCreated, doc) +} + +func (a *app) patchPage(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 + } + expectedUpdatedAt, hasExpectedUpdatedAt, err := optionalExpectedUpdatedAt(patch) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + delete(patch, "expectedUpdatedAt") + + doc, unset, err := a.preparePagePatchPayload(Doc(patch), id) + if err != nil { + a.writePageValidationError(w, err) + return + } + + if hasExpectedUpdatedAt { + doc, err = a.patchPageAtRevision(id, expectedUpdatedAt, doc, unset) + } else { + doc, err = a.patchWithUnset("pages", id, doc, unset) + } + if err != nil { + if errors.Is(err, ErrNotFound) { + if hasExpectedUpdatedAt { + if _, loadErr := a.getByID("pages", id); loadErr == nil { + writeError(w, http.StatusConflict, "page changed since it was opened; reload before saving") + return + } + } + writeError(w, http.StatusNotFound, "resource not found") + return + } + writeError(w, http.StatusInternalServerError, "failed to update resource") + return + } + writeJSON(w, http.StatusOK, doc) +} + +func optionalExpectedUpdatedAt(payload map[string]any) (int64, bool, error) { + value, exists := payload["expectedUpdatedAt"] + if !exists { + return 0, false, nil + } + number, ok := numericValue(value) + if !ok || number <= 0 || number != float64(int64(number)) { + return 0, false, errors.New("expectedUpdatedAt must be a positive integer") + } + return int64(number), true, nil +} + +func (a *app) patchPageAtRevision(id string, expectedUpdatedAt int64, patch, unset Doc) (Doc, error) { + collection, err := a.resolveCollection("pages") + if err != nil { + return nil, err + } + delete(patch, "id") + delete(patch, "createdAt") + patch["updatedAt"] = time.Now().UnixMilli() + update := Doc{"$set": patch} + if len(unset) > 0 { + update["$unset"] = unset + } + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + var updated Doc + err = collection.FindOneAndUpdate( + ctx, + Doc{"id": id, "updatedAt": expectedUpdatedAt}, + update, + NewFindOneAndUpdateOptions().SetReturnDocument(ReturnAfter), + ).Decode(&updated) + if err != nil { + return nil, err + } + normalizeDocument(updated) + return updated, nil +} + +func (a *app) getPublicPageBySlug(w http.ResponseWriter, r *http.Request) { + if a.db == nil { + writeError(w, http.StatusServiceUnavailable, "database not configured") + return + } + + slug := normalizeSlug(chi.URLParam(r, "slug")) + if slug == "" { + writeError(w, http.StatusBadRequest, "slug is required") + return + } + + ctx, cancel := context.WithTimeout(r.Context(), 8*time.Second) + defer cancel() + + var doc Doc + err := a.db.Collection("pages").FindOne(ctx, Doc{"slug": slug}).Decode(&doc) + if err != nil { + if errors.Is(err, ErrNotFound) { + writeError(w, http.StatusNotFound, "page not found") + return + } + writeError(w, http.StatusInternalServerError, "failed to load page") + return + } + + normalizeDocument(doc) + if !isPublishedPublicPage(doc) { + writeError(w, http.StatusNotFound, "page not found") + return + } + + writeJSON(w, http.StatusOK, doc) +} + +func (a *app) preparePagePatchPayload(payload Doc, pageID string) (Doc, Doc, error) { + if payload == nil { + payload = Doc{} + } + + unset := Doc{} + + if rawSlug, ok := payload["slug"]; ok { + slug, isString := rawSlug.(string) + if !isString { + return nil, nil, validationError{field: "slug", message: "slug must be a string"} + } + if strings.TrimSpace(slug) == "" { + unset["slug"] = "" + delete(payload, "slug") + } + } + + if rawSEO, ok := payload["seo"]; ok { + if rawSEO == nil { + unset["seo"] = "" + delete(payload, "seo") + } else { + seo, hasSEO, err := normalizeSEOMetadata(rawSEO) + if err != nil { + return nil, nil, err + } + if hasSEO { + payload["seo"] = seo + } else { + unset["seo"] = "" + delete(payload, "seo") + } + } + } + + doc, err := a.preparePagePayload(payload, pageID, true) + if err != nil { + return nil, nil, err + } + + return doc, unset, nil +} + +func (a *app) preparePagePayload(payload Doc, pageID string, partial bool) (Doc, error) { + if payload == nil { + payload = Doc{} + } + + name, hasName, err := getOptionalString(payload, "name") + if err != nil { + return nil, validationError{field: "name", message: "name must be a string"} + } + if !partial && (!hasName || name == "") { + return nil, validationError{field: "name", message: "name is required"} + } + + rawSlug, hasSlug, err := getOptionalString(payload, "slug") + if err != nil { + return nil, validationError{field: "slug", message: "slug must be a string"} + } + + if !hasSlug && !partial && name != "" { + rawSlug = slugify(name) + hasSlug = true + } + + if hasSlug { + slug := normalizeSlug(rawSlug) + if slug == "" { + return nil, validationError{field: "slug", message: "slug is required"} + } + if !slugPattern.MatchString(slug) { + return nil, validationError{field: "slug", message: "slug must contain lowercase letters, numbers, and hyphens only"} + } + inUse, err := a.pageSlugExists(slug, pageID) + if err != nil { + return nil, err + } + if inUse { + return nil, validationError{field: "slug", message: "slug already exists"} + } + payload["slug"] = slug + } + + seo, hasSEO, err := normalizeSEOMetadata(payload["seo"]) + if err != nil { + return nil, err + } + if hasSEO { + payload["seo"] = seo + } + if payload["seo"] == nil { + delete(payload, "seo") + } + + return payload, nil +} + +func (a *app) pageSlugExists(slug, excludeID string) (bool, error) { + if a.pageSlugInUse != nil { + return a.pageSlugInUse(slug, excludeID) + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + filter := Doc{"slug": slug} + if excludeID != "" { + filter["id"] = Doc{"$ne": excludeID} + } + + count, err := a.db.Collection("pages").CountDocuments(ctx, filter) + if err != nil { + return false, fmt.Errorf("check page slug uniqueness: %w", err) + } + return count > 0, nil +} + +type validationError struct { + field string + message string +} + +func (e validationError) Error() string { + return e.message +} + +func (a *app) writePageValidationError(w http.ResponseWriter, err error) { + var validationErr validationError + if errors.As(err, &validationErr) { + writeJSON(w, http.StatusBadRequest, map[string]any{ + "error": validationErr.message, + "field": validationErr.field, + "message": validationErr.message, + }) + return + } + writeError(w, http.StatusInternalServerError, "failed to validate page payload") +} + +func normalizeSEOMetadata(raw any) (Doc, bool, error) { + if raw == nil { + return nil, false, nil + } + + meta, ok := raw.(map[string]any) + if !ok { + if docMeta, docOK := raw.(Doc); docOK { + meta = map[string]any(docMeta) + } else { + return nil, false, validationError{field: "seo", message: "seo must be an object"} + } + } + + out := Doc{} + + for _, field := range []string{"title", "description", "canonicalUrl"} { + value, exists, err := getOptionalString(meta, field) + if err != nil { + return nil, false, validationError{field: "seo." + field, message: field + " must be a string"} + } + if exists { + out[field] = value + } + } + + if canonicalURL, ok := out["canonicalUrl"].(string); ok && canonicalURL != "" { + if err := validateAbsoluteURL(canonicalURL); err != nil { + return nil, false, validationError{field: "seo.canonicalUrl", message: "canonicalUrl must be an absolute http or https URL"} + } + } + + openGraph, hasOpenGraph, err := normalizeOpenGraph(meta["openGraph"]) + if err != nil { + return nil, false, err + } + if hasOpenGraph { + out["openGraph"] = openGraph + } + + robots, hasRobots, err := normalizeRobots(meta["robots"]) + if err != nil { + return nil, false, err + } + if hasRobots { + out["robots"] = robots + } + + if len(out) == 0 { + return nil, false, nil + } + + return out, true, nil +} + +func normalizeOpenGraph(raw any) (Doc, bool, error) { + if raw == nil { + return nil, false, nil + } + + meta, ok := raw.(map[string]any) + if !ok { + if docMeta, docOK := raw.(Doc); docOK { + meta = map[string]any(docMeta) + } else { + return nil, false, validationError{field: "seo.openGraph", message: "openGraph must be an object"} + } + } + + out := Doc{} + for _, field := range []string{"title", "description", "image", "url", "type"} { + value, exists, err := getOptionalString(meta, field) + if err != nil { + return nil, false, validationError{field: "seo.openGraph." + field, message: field + " must be a string"} + } + if exists { + out[field] = value + } + } + + for _, field := range []string{"image", "url"} { + value, ok := out[field].(string) + if ok && value != "" { + if err := validateAbsoluteURL(value); err != nil { + return nil, false, validationError{field: "seo.openGraph." + field, message: field + " must be an absolute http or https URL"} + } + } + } + + if len(out) == 0 { + return nil, false, nil + } + + return out, true, nil +} + +func normalizeRobots(raw any) (Doc, bool, error) { + if raw == nil { + return nil, false, nil + } + + meta, ok := raw.(map[string]any) + if !ok { + if docMeta, docOK := raw.(Doc); docOK { + meta = map[string]any(docMeta) + } else { + return nil, false, validationError{field: "seo.robots", message: "robots must be an object"} + } + } + + out := Doc{} + for _, field := range []string{"index", "follow", "noarchive", "nosnippet"} { + value, exists, err := getOptionalBool(meta, field) + if err != nil { + return nil, false, validationError{field: "seo.robots." + field, message: field + " must be a boolean"} + } + if exists { + out[field] = value + } + } + + if len(out) == 0 { + return nil, false, nil + } + + return out, true, nil +} + +func getOptionalString(payload map[string]any, field string) (string, bool, error) { + raw, ok := payload[field] + if !ok || raw == nil { + return "", false, nil + } + value, ok := raw.(string) + if !ok { + return "", false, errors.New("not a string") + } + return strings.TrimSpace(value), true, nil +} + +func getOptionalBool(payload map[string]any, field string) (bool, bool, error) { + raw, ok := payload[field] + if !ok || raw == nil { + return false, false, nil + } + value, ok := raw.(bool) + if !ok { + return false, false, errors.New("not a boolean") + } + return value, true, nil +} + +func normalizeSlug(value string) string { + value = strings.TrimSpace(strings.ToLower(value)) + value = strings.Trim(value, "/") + value = strings.ReplaceAll(value, "_", "-") + value = strings.Join(strings.Fields(value), "-") + + var builder strings.Builder + lastHyphen := false + for _, r := range value { + switch { + case r >= 'a' && r <= 'z', r >= '0' && r <= '9': + builder.WriteRune(r) + lastHyphen = false + case r == '-': + if builder.Len() > 0 && !lastHyphen { + builder.WriteRune(r) + lastHyphen = true + } + } + } + + return strings.Trim(builder.String(), "-") +} + +func slugify(value string) string { + return normalizeSlug(value) +} + +func validateAbsoluteURL(value string) error { + parsed, err := url.Parse(value) + if err != nil { + return err + } + if parsed.Scheme != "http" && parsed.Scheme != "https" { + return errors.New("unsupported scheme") + } + if parsed.Host == "" { + return errors.New("missing host") + } + return nil +} \ No newline at end of file diff --git a/Backend/cmd/api/permissions.go b/Backend/cmd/api/permissions.go new file mode 100644 index 0000000..5286798 --- /dev/null +++ b/Backend/cmd/api/permissions.go @@ -0,0 +1,95 @@ +package main + +import "strings" + +const ( + permKeycloakThemesRead = "keycloak_themes.read" + permKeycloakThemesCreate = "keycloak_themes.create" + permKeycloakThemesEdit = "keycloak_themes.edit" + permKeycloakThemesPublish = "keycloak_themes.publish" + permKeycloakThemesArchive = "keycloak_themes.archive" + permKeycloakThemesDeploy = "keycloak_themes.deploy" + permKeycloakThemesPreview = "keycloak_themes.preview" + permKeycloakThemesPolicyManage = "keycloak_themes.policy.manage" + permKeycloakThemesManagePlatformDefaults = "keycloak_themes.manage_platform_defaults" + permKeycloakThemesManageOrgOverrides = "keycloak_themes.manage_org_overrides" +) + +var keycloakThemePermissionRegistry = []string{ + permKeycloakThemesRead, + permKeycloakThemesCreate, + permKeycloakThemesEdit, + permKeycloakThemesPublish, + permKeycloakThemesArchive, + permKeycloakThemesDeploy, + permKeycloakThemesPreview, + permKeycloakThemesPolicyManage, + permKeycloakThemesManagePlatformDefaults, + permKeycloakThemesManageOrgOverrides, +} + +var rolePermissionRegistry = map[string][]string{ + "admin": {"*"}, + "platform_owner": {"keycloak_themes.*"}, + "platform_admin": keycloakThemePermissionRegistry, + "platform_content_reviewer": {permKeycloakThemesRead, permKeycloakThemesPreview}, + "organisation_owner": { + permKeycloakThemesRead, + permKeycloakThemesEdit, + permKeycloakThemesPreview, + permKeycloakThemesManageOrgOverrides, + }, + "organisation_admin": { + permKeycloakThemesRead, + permKeycloakThemesEdit, + permKeycloakThemesPreview, + }, +} + +func hasAnyRole(claims *accessClaims, roles ...string) bool { + for _, role := range roles { + if hasRole(claims, role) { + return true + } + } + return false +} + +func hasPermission(claims *accessClaims, permission string) bool { + if claims == nil { + return false + } + + for _, candidate := range claims.Permissions { + if permissionMatch(candidate, permission) { + return true + } + } + + for _, role := range claims.RealmAccess.Roles { + grants, ok := rolePermissionRegistry[role] + if !ok { + continue + } + for _, grant := range grants { + if permissionMatch(grant, permission) { + return true + } + } + } + + return false +} + +func permissionMatch(grant, permission string) bool { + if grant == "*" || grant == permission { + return true + } + + if strings.HasSuffix(grant, ".*") { + prefix := strings.TrimSuffix(grant, "*") + return strings.HasPrefix(permission, prefix) + } + + return false +} diff --git a/Backend/cmd/api/platform_admin.go b/Backend/cmd/api/platform_admin.go new file mode 100644 index 0000000..6dadd8f --- /dev/null +++ b/Backend/cmd/api/platform_admin.go @@ -0,0 +1,120 @@ +package main + +import ( + "errors" + "net/http" + "strings" + + "github.com/go-chi/chi/v5" +) + +func (a *app) platformAdminMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + claims, _ := r.Context().Value(userClaimsKey).(*accessClaims) + if claims == nil { + writeError(w, http.StatusUnauthorized, "no authenticated user") + return + } + if !hasAnyRole(claims, "admin", "platform_owner", "platform_admin") { + writeError(w, http.StatusForbidden, "platform admin role required") + return + } + next.ServeHTTP(w, r) + }) +} + +func (a *app) listTenants(w http.ResponseWriter, r *http.Request) { + docs, err := a.list("tenants", Doc{}, Sort{{Key: "name", Value: 1}}) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to list tenants") + return + } + writeJSON(w, http.StatusOK, docs) +} + +func (a *app) createTenant(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 + } + name := strings.TrimSpace(anyToString(payload["name"])) + if name == "" { + writeError(w, http.StatusBadRequest, "name is required") + return + } + payload["name"] = name + if slug := strings.TrimSpace(anyToString(payload["slug"])); slug != "" { + payload["slug"] = slugify(slug) + } else { + payload["slug"] = slugify(name) + } + doc, err := a.create("tenants", Doc(payload)) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to create tenant") + return + } + writeJSON(w, http.StatusCreated, doc) +} + +func (a *app) getTenant(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + doc, err := a.getByID("tenants", id) + if err != nil { + if errors.Is(err, ErrNotFound) { + writeError(w, http.StatusNotFound, "tenant not found") + return + } + writeError(w, http.StatusInternalServerError, "failed to load tenant") + return + } + writeJSON(w, http.StatusOK, doc) +} + +func (a *app) patchTenant(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 + } + if rawName, ok := patch["name"]; ok { + name := strings.TrimSpace(anyToString(rawName)) + if name == "" { + writeError(w, http.StatusBadRequest, "name cannot be empty") + return + } + patch["name"] = name + } + if rawSlug, ok := patch["slug"]; ok { + slug := strings.TrimSpace(anyToString(rawSlug)) + if slug == "" { + delete(patch, "slug") + } else { + patch["slug"] = slugify(slug) + } + } + doc, err := a.patch("tenants", id, Doc(patch)) + if err != nil { + if errors.Is(err, ErrNotFound) { + writeError(w, http.StatusNotFound, "tenant not found") + return + } + writeError(w, http.StatusInternalServerError, "failed to update tenant") + return + } + writeJSON(w, http.StatusOK, doc) +} + +func (a *app) deleteTenant(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + if err := a.deleteByID("tenants", id); err != nil { + if errors.Is(err, ErrNotFound) { + writeError(w, http.StatusNotFound, "tenant not found") + return + } + writeError(w, http.StatusInternalServerError, "failed to delete tenant") + return + } + w.WriteHeader(http.StatusNoContent) +} diff --git a/Backend/cmd/api/postgres_store.go b/Backend/cmd/api/postgres_store.go new file mode 100644 index 0000000..6476eed --- /dev/null +++ b/Backend/cmd/api/postgres_store.go @@ -0,0 +1,898 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "sort" + "strings" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" + "github.com/jackc/pgx/v5/pgconn" +) + +type pgDatabase struct { + pool *pgxpool.Pool +} + +type pgCollection struct { + db *pgDatabase + spec relationalResourceSpec +} + +type pgCursor struct { + docs []Doc + index int +} + +type pgSingleResult struct { + doc Doc + err error +} + +type pgDeleteResult struct { + DeletedCount int64 +} + +type pgUpdateResult struct { + ModifiedCount int64 +} + +type pgInsertResult struct{} +type pgReplaceResult struct{} + +func openPostgres(ctx context.Context, databaseURL string) (*pgDatabase, error) { + if strings.TrimSpace(databaseURL) == "" { + return nil, errors.New("DATABASE_URL is required") + } + pool, err := pgxpool.New(ctx, databaseURL) + if err != nil { + return nil, fmt.Errorf("configure postgres: %w", err) + } + db := &pgDatabase{pool: pool} + if err := db.Ping(ctx); err != nil { + pool.Close() + return nil, fmt.Errorf("ping postgres: %w", err) + } + if err := db.ensureRelationalSchema(ctx); err != nil { + pool.Close() + return nil, fmt.Errorf("initialize relational postgres schema: %w", err) + } + return db, nil +} + +func (d *pgDatabase) Ping(ctx context.Context) error { + return d.pool.Ping(ctx) +} + +func (d *pgDatabase) Close() { + if d != nil && d.pool != nil { + d.pool.Close() + } +} + +func (d *pgDatabase) Collection(name string) *pgCollection { + spec, ok := relationalResources[name] + if !ok { + return &pgCollection{db: d, spec: relationalResourceSpec{Name: name, Table: name}} + } + return &pgCollection{db: d, spec: spec} +} + +func (c *pgCollection) Name() string { + return c.spec.Name +} + +func (c *pgCollection) valid() error { + if _, ok := relationalResources[c.spec.Name]; !ok { + return fmt.Errorf("unknown relational resource %s", c.spec.Name) + } + return nil +} + +func (c *pgCollection) Find(ctx context.Context, filter Doc, opts ...*FindOptions) (*pgCursor, error) { + if err := c.valid(); err != nil { + return nil, err + } + args := []any{} + where, err := c.buildFilter(filter, &args) + if err != nil { + return nil, err + } + query := "SELECT " + c.documentExpression() + " FROM " + relationalIdent(c.spec.Table) + " WHERE " + where + if len(opts) > 0 && opts[0] != nil { + query += c.sortClause(opts[0].Sort) + if opts[0].Limit != nil && *opts[0].Limit > 0 { + args = append(args, *opts[0].Limit) + query += fmt.Sprintf(" LIMIT $%d", len(args)) + } + } + rows, err := c.db.pool.Query(ctx, query, args...) + if err != nil { + return nil, err + } + defer rows.Close() + + docs := make([]Doc, 0) + for rows.Next() { + var raw []byte + if err := rows.Scan(&raw); err != nil { + return nil, err + } + doc, err := decodeRelationalDocument(raw) + if err != nil { + return nil, err + } + docs = append(docs, doc) + } + if err := rows.Err(); err != nil { + return nil, err + } + return &pgCursor{docs: docs}, nil +} + +func (c *pgCollection) FindOne(ctx context.Context, filter Doc, opts ...*FindOneOptions) *pgSingleResult { + if err := c.valid(); err != nil { + return &pgSingleResult{err: err} + } + args := []any{} + where, err := c.buildFilter(filter, &args) + if err != nil { + return &pgSingleResult{err: err} + } + query := "SELECT " + c.documentExpression() + " FROM " + relationalIdent(c.spec.Table) + " WHERE " + where + if len(opts) > 0 && opts[0] != nil { + query += c.sortClause(opts[0].Sort) + } + query += " LIMIT 1" + + var raw []byte + if err := c.db.pool.QueryRow(ctx, query, args...).Scan(&raw); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + err = ErrNotFound + } + return &pgSingleResult{err: err} + } + doc, err := decodeRelationalDocument(raw) + return &pgSingleResult{doc: doc, err: err} +} + +func (c *pgCollection) CountDocuments(ctx context.Context, filter Doc, _ ...*CountOptions) (int64, error) { + if err := c.valid(); err != nil { + return 0, err + } + args := []any{} + where, err := c.buildFilter(filter, &args) + if err != nil { + return 0, err + } + var count int64 + if err := c.db.pool.QueryRow(ctx, "SELECT COUNT(*) FROM "+c.spec.Table+" WHERE "+where, args...).Scan(&count); err != nil { + return 0, err + } + return count, nil +} + +func (c *pgCollection) InsertOne(ctx context.Context, doc any, _ ...*InsertOneOptions) (*pgInsertResult, error) { + if err := c.valid(); err != nil { + return nil, err + } + normalized, err := normalizeRelationalDocument(doc) + if err != nil { + return nil, err + } + tx, err := c.db.pool.Begin(ctx) + if err != nil { + return nil, err + } + defer tx.Rollback(ctx) + if err := c.writeDocument(ctx, tx, normalized, false); err != nil { + return nil, err + } + if err := tx.Commit(ctx); err != nil { + return nil, err + } + return &pgInsertResult{}, nil +} + +func (c *pgCollection) ReplaceOne(ctx context.Context, filter any, replacement any, _ ...*ReplaceOptions) (*pgReplaceResult, error) { + if err := c.valid(); err != nil { + return nil, err + } + normalized, err := normalizeRelationalDocument(replacement) + if err != nil { + return nil, err + } + if _, ok := normalized["id"]; !ok { + if filterMap, ok := asDocMap(filter); ok { + if id, exists := filterMap["id"]; exists { + normalized["id"] = id + } + } + } + tx, err := c.db.pool.Begin(ctx) + if err != nil { + return nil, err + } + defer tx.Rollback(ctx) + if err := c.writeDocument(ctx, tx, normalized, true); err != nil { + return nil, err + } + if err := tx.Commit(ctx); err != nil { + return nil, err + } + return &pgReplaceResult{}, nil +} + +func (c *pgCollection) DeleteOne(ctx context.Context, filter Doc, _ ...*DeleteOptions) (*pgDeleteResult, error) { + if err := c.valid(); err != nil { + return nil, err + } + args := []any{} + where, err := c.buildFilter(filter, &args) + if err != nil { + return nil, err + } + query := "DELETE FROM " + relationalIdent(c.spec.Table) + " WHERE " + relationalIdent("id") + " = (SELECT " + relationalIdent("id") + " FROM " + relationalIdent(c.spec.Table) + " WHERE " + where + " LIMIT 1)" + tag, err := c.db.pool.Exec(ctx, query, args...) + if err != nil { + return nil, err + } + return &pgDeleteResult{DeletedCount: tag.RowsAffected()}, nil +} + +func (c *pgCollection) DeleteMany(ctx context.Context, filter Doc, _ ...*DeleteOptions) (*pgDeleteResult, error) { + if err := c.valid(); err != nil { + return nil, err + } + args := []any{} + where, err := c.buildFilter(filter, &args) + if err != nil { + return nil, err + } + tag, err := c.db.pool.Exec(ctx, "DELETE FROM "+relationalIdent(c.spec.Table)+" WHERE "+where, args...) + if err != nil { + return nil, err + } + return &pgDeleteResult{DeletedCount: tag.RowsAffected()}, nil +} + +func (c *pgCollection) FindOneAndUpdate(ctx context.Context, filter any, update any, _ ...*FindOneAndUpdateOptions) *pgSingleResult { + if err := c.valid(); err != nil { + return &pgSingleResult{err: err} + } + filterMap, ok := asDocMap(filter) + if !ok { + return &pgSingleResult{err: errors.New("invalid filter")} + } + updateMap, ok := asDocMap(update) + if !ok { + return &pgSingleResult{err: errors.New("invalid update")} + } + + tx, err := c.db.pool.Begin(ctx) + if err != nil { + return &pgSingleResult{err: err} + } + defer tx.Rollback(ctx) + + args := []any{} + where, err := c.buildFilter(filterMap, &args) + if err != nil { + return &pgSingleResult{err: err} + } + var raw []byte + query := "SELECT " + c.documentExpression() + " FROM " + relationalIdent(c.spec.Table) + " WHERE " + where + " LIMIT 1 FOR UPDATE" + if err := tx.QueryRow(ctx, query, args...).Scan(&raw); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + err = ErrNotFound + } + return &pgSingleResult{err: err} + } + doc, err := decodeRelationalDocument(raw) + if err != nil { + return &pgSingleResult{err: err} + } + applyRelationalUpdate(doc, updateMap) + if err := c.writeDocument(ctx, tx, doc, true); err != nil { + return &pgSingleResult{err: err} + } + if err := tx.Commit(ctx); err != nil { + return &pgSingleResult{err: err} + } + return &pgSingleResult{doc: doc} +} + +func (c *pgCollection) UpdateMany(ctx context.Context, filter any, update any, _ ...*UpdateOptions) (*pgUpdateResult, error) { + if err := c.valid(); err != nil { + return nil, err + } + filterMap, ok := asDocMap(filter) + if !ok { + return nil, errors.New("invalid filter") + } + updateMap, ok := asDocMap(update) + if !ok { + return nil, errors.New("invalid update") + } + + tx, err := c.db.pool.Begin(ctx) + if err != nil { + return nil, err + } + defer tx.Rollback(ctx) + + args := []any{} + where, err := c.buildFilter(filterMap, &args) + if err != nil { + return nil, err + } + rows, err := tx.Query(ctx, "SELECT "+c.documentExpression()+" FROM "+relationalIdent(c.spec.Table)+" WHERE "+where+" FOR UPDATE", args...) + if err != nil { + return nil, err + } + docs := make([]Doc, 0) + for rows.Next() { + var raw []byte + if err := rows.Scan(&raw); err != nil { + rows.Close() + return nil, err + } + doc, err := decodeRelationalDocument(raw) + if err != nil { + rows.Close() + return nil, err + } + docs = append(docs, doc) + } + if err := rows.Err(); err != nil { + rows.Close() + return nil, err + } + rows.Close() + + var modified int64 + for _, doc := range docs { + applyRelationalUpdate(doc, updateMap) + if err := c.writeDocument(ctx, tx, doc, true); err != nil { + return nil, err + } + modified++ + } + if err := tx.Commit(ctx); err != nil { + return nil, err + } + return &pgUpdateResult{ModifiedCount: modified}, nil +} + +type relationalExecer interface { + Exec(context.Context, string, ...any) (interface{ RowsAffected() int64 }, error) +} + +type pgxExecer interface { + Exec(context.Context, string, ...any) (pgconnCommandTag, error) +} + +type pgconnCommandTag interface { + RowsAffected() int64 +} + +func (c *pgCollection) writeDocument(ctx context.Context, exec interface { + Exec(context.Context, string, ...any) (pgconn.CommandTag, error) +}, doc Doc, upsert bool) error { + if err := c.validateDocument(doc); err != nil { + return err + } + id := strings.TrimSpace(documentString(doc["id"])) + if id == "" { + return errors.New("document id is required") + } + + fields := c.spec.columnFields() + columns := make([]string, 0, len(fields)) + placeholders := make([]string, 0, len(fields)) + args := make([]any, 0, len(fields)) + updateParts := make([]string, 0, len(fields)) + + for _, field := range fields { + value, exists := doc[field] + if !exists { + value = nil + if field == "createdAt" || field == "updatedAt" { + value = int64(0) + } + } + column := relationalColumnName(field) + columns = append(columns, relationalIdent(column)) + args = append(args, relationalDBValue(c.spec, field, value)) + placeholder := fmt.Sprintf("$%d", len(args)) + if c.spec.isJSON(field) { + placeholder += "::jsonb" + } + placeholders = append(placeholders, placeholder) + if field != "id" { + updateParts = append(updateParts, relationalIdent(column)+" = EXCLUDED."+relationalIdent(column)) + } + } + + if len(columns) == 0 { + return errors.New("id must be present in relational write") + } + query := fmt.Sprintf("INSERT INTO %s (%s) VALUES (%s)", relationalIdent(c.spec.Table), strings.Join(columns, ", "), strings.Join(placeholders, ", ")) + if upsert { + query += " ON CONFLICT (" + relationalIdent("id") + ") DO UPDATE SET " + strings.Join(updateParts, ", ") + } + if _, err := exec.Exec(ctx, query, args...); err != nil { + return err + } + return c.syncListRelations(ctx, exec, doc) +} + +func (c *pgCollection) validateDocument(doc Doc) error { + for field, value := range doc { + if strings.HasPrefix(field, "_") { + continue + } + if c.spec.hasField(field) { + continue + } + if value == nil { + continue + } + return fmt.Errorf("field %q is not mapped for relational resource %s", field, c.spec.Name) + } + return nil +} + +func (c *pgCollection) buildFilter(filter Doc, args *[]any) (string, error) { + if len(filter) == 0 { + return "TRUE", nil + } + keys := make([]string, 0, len(filter)) + for key := range filter { + keys = append(keys, key) + } + sort.Strings(keys) + + clauses := make([]string, 0, len(filter)) + for _, field := range keys { + raw := filter[field] + if field == "$or" { + items, ok := asAnySlice(raw) + if !ok || len(items) == 0 { + return "", errors.New("$or requires a non-empty array") + } + parts := make([]string, 0, len(items)) + for _, item := range items { + sub, ok := asDocMap(item) + if !ok { + return "", errors.New("$or entries must be objects") + } + clause, err := c.buildFilter(sub, args) + if err != nil { + return "", err + } + parts = append(parts, "("+clause+")") + } + clauses = append(clauses, "("+strings.Join(parts, " OR ")+")") + continue + } + if relation, ok := c.spec.ListRelations[field]; ok { + clause, err := c.listRelationFilterClause(relation, raw, args) + if err != nil { + return "", err + } + clauses = append(clauses, clause) + continue + } + if !c.spec.hasColumn(field) { + return "", fmt.Errorf("field %q is not queryable on %s", field, c.spec.Name) + } + column := relationalIdent(relationalColumnName(field)) + if operators, ok := asDocMap(raw); ok && containsRelationalOperator(operators) { + for operator, operand := range operators { + clause, err := c.operatorClause(column, field, operator, operand, args) + if err != nil { + return "", err + } + clauses = append(clauses, clause) + } + continue + } + argsValue := relationalDBValue(c.spec, field, raw) + *args = append(*args, argsValue) + if c.spec.isJSON(field) { + clauses = append(clauses, fmt.Sprintf("%s = $%d::jsonb", column, len(*args))) + } else { + clauses = append(clauses, fmt.Sprintf("%s = $%d", column, len(*args))) + } + } + if len(clauses) == 0 { + return "TRUE", nil + } + return strings.Join(clauses, " AND "), nil +} + +func (c *pgCollection) operatorClause(column, field, operator string, operand any, args *[]any) (string, error) { + switch operator { + case "$exists": + exists, ok := operand.(bool) + if !ok { + return "", errors.New("$exists requires a boolean") + } + if exists { + return column + " IS NOT NULL", nil + } + return column + " IS NULL", nil + case "$ne": + *args = append(*args, relationalDBValue(c.spec, field, operand)) + if c.spec.isJSON(field) { + return fmt.Sprintf("COALESCE(%s <> $%d::jsonb, TRUE)", column, len(*args)), nil + } + return fmt.Sprintf("COALESCE(%s <> $%d, TRUE)", column, len(*args)), nil + case "$in": + values, ok := asAnySlice(operand) + if !ok || len(values) == 0 { + return "FALSE", nil + } + parts := make([]string, 0, len(values)) + for _, value := range values { + *args = append(*args, relationalDBValue(c.spec, field, value)) + if c.spec.isJSON(field) { + parts = append(parts, fmt.Sprintf("(%s = $%d::jsonb OR %s @> jsonb_build_array($%d::jsonb))", column, len(*args), column, len(*args))) + } else { + parts = append(parts, fmt.Sprintf("%s = $%d", column, len(*args))) + } + } + return "(" + strings.Join(parts, " OR ") + ")", nil + case "$gt", "$gte", "$lt", "$lte": + if c.spec.isJSON(field) { + return "", fmt.Errorf("ordered comparison is not supported for JSON field %s", field) + } + op := map[string]string{"$gt": ">", "$gte": ">=", "$lt": "<", "$lte": "<="}[operator] + *args = append(*args, relationalDBValue(c.spec, field, operand)) + return fmt.Sprintf("%s %s $%d", column, op, len(*args)), nil + default: + return "", fmt.Errorf("unsupported relational operator %s", operator) + } +} + +func (c *pgCollection) sortClause(raw any) string { + sortDoc, ok := raw.(Sort) + if !ok || len(sortDoc) == 0 { + return "" + } + parts := make([]string, 0, len(sortDoc)) + for _, item := range sortDoc { + if !c.spec.hasColumn(item.Key) || c.spec.isJSON(item.Key) { + continue + } + direction := "ASC" + if number, ok := numericValue(item.Value); ok && number < 0 { + direction = "DESC" + } + parts = append(parts, relationalIdent(relationalColumnName(item.Key))+" "+direction+" NULLS LAST") + } + if len(parts) == 0 { + return "" + } + return " ORDER BY " + strings.Join(parts, ", ") +} + +func (c *pgCollection) documentExpression() string { + parts := make([]string, 0, len(c.spec.allFields())*2) + for _, field := range c.spec.columnFields() { + parts = append(parts, fmt.Sprintf("'%s'", field), relationalIdent(relationalColumnName(field))) + } + relationFields := make([]string, 0, len(c.spec.ListRelations)) + for field := range c.spec.ListRelations { + relationFields = append(relationFields, field) + } + sort.Strings(relationFields) + for _, field := range relationFields { + relation := c.spec.ListRelations[field] + expr := fmt.Sprintf( + "(SELECT jsonb_agg(%s ORDER BY %s) FROM %s rel WHERE rel.%s = %s.%s)", + "rel."+relationalIdent(relation.TargetColumn), + "rel."+relationalIdent(relation.TargetColumn), + relationalIdent(relation.Table), + relationalIdent(relation.OwnerColumn), + relationalIdent(c.spec.Table), + relationalIdent("id"), + ) + parts = append(parts, fmt.Sprintf("'%s'", field), expr) + } + return "jsonb_strip_nulls(jsonb_build_object(" + strings.Join(parts, ", ") + "))" +} + +func (c *pgCollection) syncListRelations(ctx context.Context, exec interface { + Exec(context.Context, string, ...any) (pgconn.CommandTag, error) +}, doc Doc) error { + ownerID := strings.TrimSpace(documentString(doc["id"])) + if ownerID == "" { + return errors.New("relation owner id is required") + } + fields := make([]string, 0, len(c.spec.ListRelations)) + for field := range c.spec.ListRelations { + fields = append(fields, field) + } + sort.Strings(fields) + for _, field := range fields { + relation := c.spec.ListRelations[field] + if _, err := exec.Exec( + ctx, + "DELETE FROM "+relationalIdent(relation.Table)+" WHERE "+relationalIdent(relation.OwnerColumn)+" = $1", + ownerID, + ); err != nil { + return err + } + raw, exists := doc[field] + if !exists || raw == nil { + continue + } + values, ok := asAnySlice(raw) + if !ok { + return fmt.Errorf("%s.%s must be an array", c.spec.Name, field) + } + for _, value := range values { + target := strings.TrimSpace(documentString(value)) + if target == "" { + continue + } + query := fmt.Sprintf( + "INSERT INTO %s (%s, %s) VALUES ($1, $2) ON CONFLICT DO NOTHING", + relationalIdent(relation.Table), + relationalIdent(relation.OwnerColumn), + relationalIdent(relation.TargetColumn), + ) + if _, err := exec.Exec(ctx, query, ownerID, target); err != nil { + return err + } + } + } + return nil +} + +func (c *pgCollection) listRelationFilterClause(relation relationalListRelation, raw any, args *[]any) (string, error) { + base := "SELECT 1 FROM " + relationalIdent(relation.Table) + " rel WHERE rel." + + relationalIdent(relation.OwnerColumn) + " = " + relationalIdent(c.spec.Table) + "." + relationalIdent("id") + + if operators, ok := asDocMap(raw); ok && containsRelationalOperator(operators) { + parts := make([]string, 0, len(operators)) + for operator, operand := range operators { + switch operator { + case "$exists": + exists, ok := operand.(bool) + if !ok { + return "", errors.New("$exists requires a boolean") + } + if exists { + parts = append(parts, "EXISTS ("+base+")") + } else { + parts = append(parts, "NOT EXISTS ("+base+")") + } + case "$in": + values, ok := asAnySlice(operand) + if !ok || len(values) == 0 { + parts = append(parts, "FALSE") + continue + } + matches := make([]string, 0, len(values)) + for _, value := range values { + *args = append(*args, documentString(value)) + matches = append(matches, "rel."+relationalIdent(relation.TargetColumn)+fmt.Sprintf(" = $%d", len(*args))) + } + parts = append(parts, "EXISTS ("+base+" AND ("+strings.Join(matches, " OR ")+"))") + case "$ne": + *args = append(*args, documentString(operand)) + parts = append(parts, "NOT EXISTS ("+base+" AND rel."+relationalIdent(relation.TargetColumn)+fmt.Sprintf(" = $%d)", len(*args))) + default: + return "", fmt.Errorf("unsupported relation operator %s", operator) + } + } + return "(" + strings.Join(parts, " AND ") + ")", nil + } + + *args = append(*args, documentString(raw)) + return "EXISTS (" + base + " AND rel." + relationalIdent(relation.TargetColumn) + fmt.Sprintf(" = $%d)", len(*args)), nil +} + +func relationalDBValue(spec relationalResourceSpec, field string, value any) any { + if value == nil { + return nil + } + if spec.isJSON(field) { + raw, err := json.Marshal(value) + if err != nil { + return "null" + } + return string(raw) + } + if isBooleanResourceField(spec.Name, field) { + return anyToBool(value) + } + if isNumericField(field) { + number, ok := numericValue(value) + if !ok { + return int64(0) + } + if field == "score" || field == "progress" { + return number + } + return int64(number) + } + switch typed := value.(type) { + case time.Time: + return typed.UTC().Format(time.RFC3339Nano) + default: + return documentString(value) + } +} + +func (r *pgSingleResult) Decode(target any) error { + if r.err != nil { + return r.err + } + return assignRelationalDocument(target, r.doc) +} + +func (c *pgCursor) Close(context.Context) error { + return nil +} + +func (c *pgCursor) All(_ context.Context, target any) error { + switch typed := target.(type) { + case *[]Doc: + out := make([]Doc, len(c.docs)) + for i, doc := range c.docs { + out[i] = cloneDoc(doc) + } + *typed = out + return nil + default: + raw, err := json.Marshal(c.docs) + if err != nil { + return err + } + return json.Unmarshal(raw, target) + } +} + +func (c *pgCursor) Next(context.Context) bool { + if c.index >= len(c.docs) { + return false + } + c.index++ + return true +} + +func (c *pgCursor) Decode(target any) error { + if c.index == 0 || c.index > len(c.docs) { + return errors.New("cursor is not positioned on a row") + } + return assignRelationalDocument(target, c.docs[c.index-1]) +} + +func assignRelationalDocument(target any, doc Doc) error { + switch typed := target.(type) { + case *Doc: + *typed = cloneDoc(doc) + return nil + default: + raw, err := json.Marshal(doc) + if err != nil { + return err + } + return json.Unmarshal(raw, target) + } +} + +func normalizeRelationalDocument(value any) (Doc, error) { + switch typed := value.(type) { + case Doc: + return cloneDoc(typed), nil + case map[string]any: + return cloneDoc(Doc(typed)), nil + default: + raw, err := json.Marshal(value) + if err != nil { + return nil, err + } + var doc Doc + if err := json.Unmarshal(raw, &doc); err != nil { + return nil, err + } + return doc, nil + } +} + +func decodeRelationalDocument(raw []byte) (Doc, error) { + var doc Doc + if err := json.Unmarshal(raw, &doc); err != nil { + return nil, err + } + return doc, nil +} + +func cloneDoc(doc Doc) Doc { + out := make(Doc, len(doc)) + for key, value := range doc { + out[key] = value + } + return out +} + +func applyRelationalUpdate(doc Doc, update Doc) { + if setRaw, ok := update["$set"]; ok { + if set, ok := asDocMap(setRaw); ok { + for key, value := range set { + doc[key] = value + } + } + } + if unsetRaw, ok := update["$unset"]; ok { + if unset, ok := asDocMap(unsetRaw); ok { + for key := range unset { + delete(doc, key) + } + } + } +} + +func containsRelationalOperator(value Doc) bool { + for key := range value { + if strings.HasPrefix(key, "$") { + return true + } + } + return false +} + +func asDocMap(value any) (Doc, bool) { + switch typed := value.(type) { + case Doc: + return typed, true + case map[string]any: + return Doc(typed), true + default: + return nil, false + } +} + +func asAnySlice(value any) ([]any, bool) { + switch typed := value.(type) { + case List: + return []any(typed), true + case []any: + return typed, true + case []string: + out := make([]any, len(typed)) + for i, value := range typed { + out[i] = value + } + return out, true + default: + return nil, false + } +} + +func documentString(value any) string { + if value == nil { + return "" + } + if text, ok := value.(string); ok { + return text + } + return fmt.Sprint(value) +} + +func documentInt64(value any) int64 { + number, ok := numericValue(value) + if !ok { + return 0 + } + return int64(number) +} diff --git a/Backend/cmd/api/publishing_policy.go b/Backend/cmd/api/publishing_policy.go new file mode 100644 index 0000000..1963f12 --- /dev/null +++ b/Backend/cmd/api/publishing_policy.go @@ -0,0 +1,60 @@ +package main + +import ( + "errors" + "strings" +) + +type publishingPolicy interface { + defaultStatus() string + isAllowedStatus(status string) bool + invalidStatusMessage() string + validate(payload Doc, existing Doc) error +} + +func applyPublishingPolicy(payload Doc, partial bool, existing Doc, policy publishingPolicy) error { + status, hasStatus, err := getOptionalString(payload, "status") + if err != nil { + return errors.New("status must be a string") + } + if !hasStatus && !partial { + status = policy.defaultStatus() + hasStatus = true + } + if hasStatus { + normalizedStatus := strings.TrimSpace(strings.ToLower(status)) + if !policy.isAllowedStatus(normalizedStatus) { + return errors.New(policy.invalidStatusMessage()) + } + payload["status"] = normalizedStatus + } + + return policy.validate(payload, existing) +} + +func mergedPublishingValue(payload Doc, existing Doc, field string) (any, bool) { + if value, ok := payload[field]; ok { + return value, true + } + if existing == nil { + return nil, false + } + value, ok := existing[field] + return value, ok +} + +func mergedPublishingString(payload Doc, existing Doc, field string) (string, bool) { + value, ok := mergedPublishingValue(payload, existing, field) + if !ok { + return "", false + } + text, ok := value.(string) + if !ok { + return "", false + } + trimmed := strings.TrimSpace(text) + if trimmed == "" { + return "", false + } + return strings.ToLower(trimmed), true +} diff --git a/Backend/cmd/api/relational_schema.go b/Backend/cmd/api/relational_schema.go new file mode 100644 index 0000000..a6106eb --- /dev/null +++ b/Backend/cmd/api/relational_schema.go @@ -0,0 +1,565 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "sort" + "strings" +) + +type relationalResourceSpec struct { + Name string + Table string + Fields []string + JSONFields map[string]struct{} + References map[string]relationalReference + ListRelations map[string]relationalListRelation +} + +type relationalListRelation struct { + Table string + OwnerColumn string + TargetColumn string + TargetTable string +} + +type relationalReference struct { + Table string + OnDelete string +} + +var relationalResources = buildRelationalResourceSpecs() + +func buildRelationalResourceSpecs() map[string]relationalResourceSpec { + specs := []relationalResourceSpec{ + resource("tenants", []string{"name", "slug", "description", "status"}, nil, nil), + resource("users", []string{"name", "displayName", "email", "username", "keycloakUsername", "role", "status", "gdprErasedAt"}, nil, + map[string]relationalReference{"tenantId": ref("tenants", "CASCADE")}), + resource("pages", []string{"name", "title", "slug", "status", "visibility", "publishedAt", "layout", "blocks", "content", "seo"}, []string{"layout", "blocks", "content", "seo"}, + map[string]relationalReference{"tenantId": ref("tenants", "CASCADE")}), + resource("datasources", []string{"name", "description", "type", "status", "config"}, []string{"config"}, + map[string]relationalReference{"tenantId": ref("tenants", "CASCADE")}), + resource("scorm_courses", []string{"title", "description", "status", "version", "visibility", "launchUrl", "scos", "manifest"}, []string{"scos", "manifest"}, + map[string]relationalReference{"tenantId": ref("tenants", "CASCADE")}), + resource("scorm_exports", []string{"courseId", "version", "storageKey"}, nil, + map[string]relationalReference{"tenantId": ref("tenants", "CASCADE"), "courseId": ref("scorm_courses", "CASCADE")}), + resource("enrollments", []string{"userId", "courseId", "courseType", "status", "score", "progress", "progressNote", "timeSpentMin", "completedAt", "archivedAt", "archivedReason", "erasedUserId", "gdprErasedAt"}, nil, + map[string]relationalReference{"tenantId": ref("tenants", "CASCADE"), "userId": ref("users", "CASCADE")}), + resource("authoring_runs", []string{"userId", "status", "brief", "proposalRevision", "source", "reviewUrl", "requiresCMSApproval", "proposal", "validation", "idempotencyKeys", "theme", "site", "page"}, []string{"proposal", "validation", "idempotencyKeys", "theme", "site", "page"}, + map[string]relationalReference{"tenantId": ref("tenants", "CASCADE"), "userId": ref("users", "SET NULL")}), + resource("consent_receipts", []string{"userId", "policyVersion", "policyUrl", "analytics", "marketing", "preferences", "necessary", "revokedAt", "revokedReason", "categories", "receipt"}, []string{"categories", "receipt"}, + map[string]relationalReference{"tenantId": ref("tenants", "CASCADE"), "userId": ref("users", "CASCADE")}), + resource("data_subject_requests", []string{"userId", "type", "status", "requestedAt", "decidedAt", "mode", "reason", "archivedAt", "archivedReason", "payload", "recordTypes"}, []string{"payload", "recordTypes"}, + map[string]relationalReference{"tenantId": ref("tenants", "CASCADE"), "userId": ref("users", "CASCADE")}), + resource("webhooks", []string{"name", "url", "secret", "status", "type", "metadata"}, []string{"metadata"}, + map[string]relationalReference{"tenantId": ref("tenants", "CASCADE")}), + resource("forms", []string{"name", "title", "slug", "description", "status", "schema", "fields"}, []string{"schema", "fields"}, + map[string]relationalReference{"tenantId": ref("tenants", "CASCADE")}), + resource("form_submissions", []string{"formId", "userId", "submittedAt", "values", "data", "metadata"}, []string{"values", "data", "metadata"}, + map[string]relationalReference{"tenantId": ref("tenants", "CASCADE"), "formId": ref("forms", "CASCADE"), "userId": ref("users", "SET NULL")}), + resource("sitemap_menus", []string{"name", "description", "items"}, []string{"items"}, + map[string]relationalReference{"tenantId": ref("tenants", "CASCADE")}), + resource("audit_events", []string{"actorUserId", "actor", "action", "resource", "resourceId", "result", "timestamp", "metadata"}, []string{"metadata"}, + map[string]relationalReference{"tenantId": ref("tenants", "CASCADE"), "actorUserId": ref("users", "SET NULL")}), + resource("assets", []string{"title", "name", "fileName", "description", "status", "visibility", "ownerUserId", "ownerName", "relatedResourceType", "relatedResourceId", "entitlementKey", "protectionLevel", "sourceUrl", "attributionText", "claimStatus", "licence", "license", "requiresAttribution", "allowDownload", "allowReuse", "allowModification", "allowCommercialUse", "encryption", "copyright", "metadata"}, []string{"encryption", "copyright", "metadata"}, + map[string]relationalReference{"tenantId": ref("tenants", "CASCADE"), "ownerUserId": ref("users", "SET NULL")}), + resource("asset_access_events", []string{"assetId", "userId", "eventType", "submittedAt", "metadata"}, []string{"metadata"}, + map[string]relationalReference{"tenantId": ref("tenants", "CASCADE"), "assetId": ref("assets", "CASCADE"), "userId": ref("users", "SET NULL")}), + resource("asset_claims", []string{"assetId", "userId", "status", "claimStatus", "submittedAt", "metadata"}, []string{"metadata"}, + map[string]relationalReference{"tenantId": ref("tenants", "CASCADE"), "assetId": ref("assets", "CASCADE"), "userId": ref("users", "SET NULL")}), + resource("notification_templates", []string{"name", "channel", "subject", "body", "enabled", "metadata"}, []string{"metadata"}, + map[string]relationalReference{"tenantId": ref("tenants", "CASCADE")}), + resource("notification_preferences", []string{"userId", "email", "inApp", "sms", "push", "enabled", "metadata"}, []string{"metadata"}, + map[string]relationalReference{"tenantId": ref("tenants", "CASCADE"), "userId": ref("users", "CASCADE")}), + resource("notification_deliveries", []string{"userId", "templateId", "channel", "to", "subject", "body", "status", "queuedAt", "sentAt", "deliveredAt", "failedAt", "deferredAt", "throttledAt", "provider", "providerMessageId", "throttleReason", "lastError", "idempotencyKey", "metadata"}, []string{"metadata"}, + map[string]relationalReference{"tenantId": ref("tenants", "CASCADE"), "userId": ref("users", "SET NULL"), "templateId": ref("notification_templates", "SET NULL")}), + resource("notification_queue", []string{"deliveryId", "channel", "to", "subject", "body", "templateId", "status", "attemptCount", "nextAttemptAt", "providerHint", "fallbackProvider", "idempotencyKey", "lastError", "metadata"}, []string{"metadata"}, + map[string]relationalReference{"tenantId": ref("tenants", "CASCADE"), "deliveryId": ref("notification_deliveries", "CASCADE"), "templateId": ref("notification_templates", "SET NULL")}), + resource("notification_delivery_attempts", []string{"deliveryId", "queueId", "attempt", "attemptStatus", "errorMessage", "providerMessageId"}, nil, + map[string]relationalReference{"tenantId": ref("tenants", "CASCADE"), "deliveryId": ref("notification_deliveries", "CASCADE"), "queueId": ref("notification_queue", "SET NULL")}), + resource("notification_dead_letters", []string{"deliveryId", "queueId", "channel", "to", "subject", "body", "templateId", "idempotencyKey", "providerHint", "fallbackProvider", "attemptCount", "reason", "movedAt", "replayedAt", "replayId", "deadLetterId", "metadata"}, []string{"metadata"}, + map[string]relationalReference{"tenantId": ref("tenants", "CASCADE"), "deliveryId": ref("notification_deliveries", "SET NULL"), "templateId": ref("notification_templates", "SET NULL")}), + resource("notification_rate_limit_policies", []string{"channel", "throughputPerMinute", "burst", "maxRetries", "backoffSeconds", "maxBackoffSeconds", "deferredSeconds", "perTemplatePerHour", "perRecipientPerHour", "quietHoursStartUtc", "quietHoursEndUtc", "channelEnabled", "throttledAction", "fallbackProvider"}, nil, + map[string]relationalReference{"tenantId": ref("tenants", "CASCADE")}), + resource("notification_provider_configs", []string{"provider", "enabled", "apiKey", "fromEmail", "apiBaseURL", "credentialMode", "source", "metadata"}, []string{"metadata"}, + map[string]relationalReference{"tenantId": ref("tenants", "CASCADE")}), + resource("notifications", []string{"userId", "title", "message", "read", "channel", "status", "metadata"}, []string{"metadata"}, + map[string]relationalReference{"tenantId": ref("tenants", "CASCADE"), "userId": ref("users", "CASCADE")}), + resource("blog_categories", []string{"name", "slug", "description", "status"}, nil, + map[string]relationalReference{"tenantId": ref("tenants", "CASCADE")}), + resource("blog_tags", []string{"name", "slug", "description", "status"}, nil, + map[string]relationalReference{"tenantId": ref("tenants", "CASCADE")}), + resource("blog_authors", []string{"name", "slug", "email", "bio", "userId", "status", "metadata"}, []string{"metadata"}, + map[string]relationalReference{"tenantId": ref("tenants", "CASCADE"), "userId": ref("users", "SET NULL")}), + resource("blog_posts", []string{"title", "slug", "status", "visibility", "excerpt", "content", "authorId", "categoryId", "publishedAt", "scheduledFor", "featured", "featuredImage", "seo", "metadata"}, []string{"content", "featuredImage", "seo", "metadata"}, + map[string]relationalReference{"tenantId": ref("tenants", "CASCADE"), "authorId": ref("blog_authors", "SET NULL"), "categoryId": ref("blog_categories", "SET NULL")}), + resource("entitlements", []string{"userId", "key", "value", "active", "status", "scope", "source", "reason", "startsAt", "endsAt", "revokedAt", "graceAt", "convertedFromTrialAt", "convertedFromEntitlementId", "initiatedByUserId", "lastUpdatedByUserId", "platformAccountId", "sourceEventId", "stripeEventId", "stripeEventType", "checkoutSessionId", "metadata"}, []string{"value", "metadata"}, + map[string]relationalReference{"tenantId": ref("tenants", "CASCADE"), "userId": ref("users", "CASCADE"), "convertedFromEntitlementId": ref("entitlements", "SET NULL")}), + resource("live_courses", []string{"title", "slug", "status", "visibility", "description", "deliveryMode", "level", "durationSummary", "communityId", "entitlementKey", "priceId", "organisationId", "publishedAt", "metadata"}, []string{"metadata"}, + map[string]relationalReference{"tenantId": ref("tenants", "CASCADE")}), + resource("live_course_cohorts", []string{"courseId", "liveCourseId", "name", "status", "startsAt", "endsAt", "timezone", "capacity", "enrolledCount", "waitlistEnabled", "priceId", "meetingProvider", "defaultMeetingUrl", "organisationId", "metadata"}, []string{"metadata"}, + map[string]relationalReference{"tenantId": ref("tenants", "CASCADE"), "courseId": ref("live_courses", "CASCADE"), "liveCourseId": ref("live_courses", "CASCADE")}), + resource("live_course_sessions", []string{"courseId", "liveCourseId", "cohortId", "title", "status", "startsAt", "endsAt", "meetingUrl", "recordingAssetId", "communityThreadId", "metadata"}, []string{"metadata"}, + map[string]relationalReference{"tenantId": ref("tenants", "CASCADE"), "cohortId": ref("live_course_cohorts", "CASCADE"), "recordingAssetId": ref("assets", "SET NULL")}), + resource("live_course_enrollments", []string{"courseId", "liveCourseId", "cohortId", "userId", "status", "source", "enrolledAt", "completedAt", "metadata"}, []string{"metadata"}, + map[string]relationalReference{"tenantId": ref("tenants", "CASCADE"), "cohortId": ref("live_course_cohorts", "CASCADE"), "userId": ref("users", "CASCADE")}), + resource("live_course_attendance", []string{"courseId", "liveCourseId", "cohortId", "sessionId", "enrollmentId", "userId", "status", "joinedAt", "leftAt", "durationSeconds", "notes", "markedBy", "metadata"}, []string{"metadata"}, + map[string]relationalReference{"tenantId": ref("tenants", "CASCADE"), "sessionId": ref("live_course_sessions", "CASCADE"), "enrollmentId": ref("live_course_enrollments", "CASCADE"), "userId": ref("users", "CASCADE")}), + resource("communities", []string{"name", "slug", "description", "status", "visibility", "accessPolicy", "requiredEntitlementKey", "metadata"}, []string{"metadata"}, + map[string]relationalReference{"tenantId": ref("tenants", "CASCADE")}), + resource("community_channels", []string{"communityId", "name", "slug", "topic", "isPrivate", "status", "metadata"}, []string{"metadata"}, + map[string]relationalReference{"tenantId": ref("tenants", "CASCADE"), "communityId": ref("communities", "CASCADE")}), + resource("community_memberships", []string{"communityId", "userId", "role", "status", "source", "metadata"}, []string{"metadata"}, + map[string]relationalReference{"tenantId": ref("tenants", "CASCADE"), "communityId": ref("communities", "CASCADE"), "userId": ref("users", "CASCADE")}), + resource("community_posts", []string{"communityId", "channelId", "authorId", "body", "status", "metadata"}, []string{"metadata"}, + map[string]relationalReference{"tenantId": ref("tenants", "CASCADE"), "communityId": ref("communities", "CASCADE"), "channelId": ref("community_channels", "CASCADE"), "authorId": ref("users", "SET NULL")}), + resource("community_replies", []string{"postId", "authorId", "body", "status", "metadata"}, []string{"metadata"}, + map[string]relationalReference{"tenantId": ref("tenants", "CASCADE"), "postId": ref("community_posts", "CASCADE"), "authorId": ref("users", "SET NULL")}), + resource("keycloak_themes", []string{"name", "slug", "version", "status", "scope", "type", "target", "organizationId", "realm", "client", "createdBy", "updatedBy", "sections", "tokens", "metadata"}, []string{"target", "sections", "tokens", "metadata"}, + map[string]relationalReference{"tenantId": ref("tenants", "CASCADE")}), + resource("keycloak_theme_policy", []string{"organizationId", "allowList", "policy", "metadata"}, []string{"allowList", "policy", "metadata"}, + map[string]relationalReference{"tenantId": ref("tenants", "CASCADE")}), + resource("keycloak_theme_exports", []string{"themeId", "themeSlug", "checksum", "format", "baseDir", "fileCount", "files", "previewOnly", "metadata"}, []string{"files", "metadata"}, + map[string]relationalReference{"tenantId": ref("tenants", "CASCADE"), "themeId": ref("keycloak_themes", "CASCADE")}), + resource("keycloak_theme_deployments_current", []string{"deploymentKey", "themeId", "themeSlug", "version", "status", "summary", "target", "realm", "client", "organizationId", "deployedBy", "deployedAt", "rollback", "rollbackThemeId", "rollbackVersion", "errorMessage"}, []string{"target", "rollback"}, + map[string]relationalReference{"tenantId": ref("tenants", "CASCADE"), "themeId": ref("keycloak_themes", "CASCADE")}), + resource("keycloak_theme_deployments_audit", []string{"deploymentKey", "themeId", "themeSlug", "version", "status", "summary", "target", "realm", "client", "organizationId", "actor", "deployedBy", "deployedAt", "rollback", "rollbackThemeId", "rollbackVersion", "errorMessage"}, []string{"target", "rollback"}, + map[string]relationalReference{"tenantId": ref("tenants", "CASCADE"), "themeId": ref("keycloak_themes", "SET NULL")}), + resource("platform_settings", []string{"platformIdentity", "seoDefaults", "consentLinks", "integrations", "features", "branding", "metadata"}, []string{"platformIdentity", "seoDefaults", "consentLinks", "integrations", "features", "branding", "metadata"}, nil), + resource("admin_settings", []string{"platformIdentity", "seoDefaults", "consentLinks", "integrations", "features", "branding", "metadata"}, []string{"platformIdentity", "seoDefaults", "consentLinks", "integrations", "features", "branding", "metadata"}, nil), + resource("billing_customers", []string{"userId", "stripeCustomerId", "email", "lastInitiatedByUserId", "scope", "status", "metadata"}, []string{"metadata"}, + map[string]relationalReference{"tenantId": ref("tenants", "CASCADE"), "userId": ref("users", "SET NULL")}), + resource("billing_events", []string{"provider", "type", "status", "receivedAt", "stripeCreatedAt", "platformAccountId", "userId", "initiatedByUserId", "processedAt", "ignored", "reason", "scope", "billingCustomerId", "initiatedByTenantId", "isPlatformActor", "checkoutSessionId", "archivedAt", "archivedReason", "data", "metadata"}, []string{"data", "metadata"}, + map[string]relationalReference{"tenantId": ref("tenants", "CASCADE"), "userId": ref("users", "SET NULL"), "billingCustomerId": ref("billing_customers", "SET NULL")}), + resource("billing_price_policies", []string{"priceId", "entitlementKey", "mode", "scope", "durationDays", "trialDays", "graceDays", "status", "metadata"}, []string{"metadata"}, + map[string]relationalReference{"tenantId": ref("tenants", "CASCADE")}), + } + + for i := range specs { + switch specs[i].Name { + case "blog_posts": + specs[i].ListRelations = map[string]relationalListRelation{ + "tagIds": {Table: "blog_post_tags", OwnerColumn: "post_id", TargetColumn: "tag_id", TargetTable: "blog_tags"}, + } + case "live_courses": + specs[i].ListRelations = map[string]relationalListRelation{ + "instructorIds": {Table: "live_course_instructors", OwnerColumn: "live_course_id", TargetColumn: "user_id", TargetTable: "users"}, + "assetIds": {Table: "live_course_assets", OwnerColumn: "live_course_id", TargetColumn: "asset_id", TargetTable: "assets"}, + } + case "communities": + specs[i].ListRelations = map[string]relationalListRelation{ + "allowedRoles": {Table: "community_allowed_roles", OwnerColumn: "community_id", TargetColumn: "role"}, + } + } + } + + out := make(map[string]relationalResourceSpec, len(specs)) + for _, spec := range specs { + out[spec.Name] = spec + } + return out +} + +func resource(name string, fields []string, jsonFields []string, references map[string]relationalReference) relationalResourceSpec { + jsonSet := map[string]struct{}{} + for _, field := range jsonFields { + jsonSet[field] = struct{}{} + } + return relationalResourceSpec{ + Name: name, + Table: name, + Fields: append([]string{}, fields...), + JSONFields: jsonSet, + References: references, + ListRelations: nil, + } +} + +func ref(table, onDelete string) relationalReference { + return relationalReference{Table: table, OnDelete: onDelete} +} + +func (s relationalResourceSpec) columnFields() []string { + fields := []string{"id", "tenantId", "createdAt", "updatedAt"} + fields = append(fields, s.Fields...) + return uniqueStrings(fields) +} + +func (s relationalResourceSpec) allFields() []string { + fields := s.columnFields() + for field := range s.ListRelations { + fields = append(fields, field) + } + return uniqueStrings(fields) +} + +func (s relationalResourceSpec) hasField(field string) bool { + for _, candidate := range s.allFields() { + if candidate == field { + return true + } + } + return false +} + +func (s relationalResourceSpec) hasColumn(field string) bool { + for _, candidate := range s.columnFields() { + if candidate == field { + return true + } + } + return false +} + +func (s relationalResourceSpec) isJSON(field string) bool { + _, ok := s.JSONFields[field] + return ok +} + +func relationalColumnName(field string) string { + if field == "tenantId" { + return "tenant_id" + } + var b strings.Builder + for i, r := range field { + if r >= 'A' && r <= 'Z' { + if i > 0 { + b.WriteByte('_') + } + b.WriteRune(r + ('a' - 'A')) + continue + } + b.WriteRune(r) + } + return b.String() +} + +func relationalSQLType(spec relationalResourceSpec, field string) string { + if spec.isJSON(field) { + return "JSONB" + } + if isBooleanResourceField(spec.Name, field) { + return "BOOLEAN" + } + if isNumericField(field) { + if field == "score" || field == "progress" { + return "DOUBLE PRECISION" + } + return "BIGINT" + } + return "TEXT" +} + +func isBooleanResourceField(resourceName, field string) bool { + switch resourceName { + case "consent_receipts": + return field == "analytics" || field == "marketing" || field == "preferences" || field == "necessary" + case "assets": + return field == "requiresAttribution" || field == "allowDownload" || field == "allowReuse" || field == "allowModification" || field == "allowCommercialUse" + case "notification_templates": + return field == "enabled" + case "notification_preferences": + return field == "email" || field == "inApp" || field == "sms" || field == "push" || field == "enabled" + case "notification_rate_limit_policies": + return field == "channelEnabled" + case "notification_provider_configs": + return field == "enabled" + case "notifications": + return field == "read" + case "entitlements": + return field == "active" + case "live_course_cohorts": + return field == "waitlistEnabled" + case "community_channels": + return field == "isPrivate" + case "authoring_runs": + return field == "requiresCMSApproval" + case "blog_posts": + return field == "featured" + case "keycloak_theme_exports": + return field == "previewOnly" + case "billing_events": + return field == "ignored" || field == "isPlatformActor" + default: + return false + } +} + +func isNumericField(field string) bool { + if strings.HasSuffix(field, "At") { + return true + } + switch field { + case "createdAt", "updatedAt", "score", "progress", "timeSpentMin", "proposalRevision", + "fileCount", "capacity", "enrolledCount", "durationSeconds", "quantity", "attempt", + "attemptCount", "retryCount", "throughputPerMinute", "burst", "maxRetries", + "backoffSeconds", "maxBackoffSeconds", "deferredSeconds", "perTemplatePerHour", + "perRecipientPerHour", "quietHoursStartUtc", "quietHoursEndUtc", "durationDays", + "trialDays", "graceDays", "limit": + return true + default: + return false + } +} + +func (d *pgDatabase) ensureRelationalSchema(ctx context.Context) error { + if _, err := d.pool.Exec(ctx, `CREATE TABLE IF NOT EXISTS schema_migrations ( + version TEXT PRIMARY KEY, + applied_at TIMESTAMPTZ NOT NULL DEFAULT now() + )`); err != nil { + return err + } + + names := make([]string, 0, len(relationalResources)) + for name := range relationalResources { + names = append(names, name) + } + sort.SliceStable(names, func(i, j int) bool { + return relationalTableRank(names[i]) < relationalTableRank(names[j]) + }) + + for _, name := range names { + spec := relationalResources[name] + if err := d.ensureRelationalTable(ctx, spec, false); err != nil { + return fmt.Errorf("create %s: %w", spec.Table, err) + } + } + for _, name := range names { + spec := relationalResources[name] + if err := d.ensureRelationalReferences(ctx, spec); err != nil { + return fmt.Errorf("reference %s: %w", spec.Table, err) + } + } + for _, name := range names { + if err := d.ensureListRelationTables(ctx, relationalResources[name]); err != nil { + return fmt.Errorf("list relations %s: %w", name, err) + } + } + + if err := d.migrateLegacyDocumentTable(ctx); err != nil { + return fmt.Errorf("migrate legacy document store: %w", err) + } + + _, err := d.pool.Exec(ctx, `INSERT INTO schema_migrations(version) + VALUES ('20260928_relational_v1') + ON CONFLICT (version) DO NOTHING`) + return err +} + +func (d *pgDatabase) ensureListRelationTables(ctx context.Context, spec relationalResourceSpec) error { + fields := make([]string, 0, len(spec.ListRelations)) + for field := range spec.ListRelations { + fields = append(fields, field) + } + sort.Strings(fields) + for _, field := range fields { + relation := spec.ListRelations[field] + query := fmt.Sprintf( + "CREATE TABLE IF NOT EXISTS %s (%s TEXT NOT NULL REFERENCES %s(id) ON DELETE CASCADE, %s TEXT NOT NULL, PRIMARY KEY (%s, %s))", + relationalIdent(relation.Table), + relationalIdent(relation.OwnerColumn), + relationalIdent(spec.Table), + relationalIdent(relation.TargetColumn), + relationalIdent(relation.OwnerColumn), + relationalIdent(relation.TargetColumn), + ) + if _, err := d.pool.Exec(ctx, query); err != nil { + return err + } + if relation.TargetTable != "" { + constraint := relation.Table + "_" + relation.TargetColumn + "_fk" + alter := fmt.Sprintf(`DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = '%s') THEN + ALTER TABLE %s ADD CONSTRAINT %s FOREIGN KEY (%s) + REFERENCES %s(id) ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED; + END IF; +END $$;`, + constraint, + relationalIdent(relation.Table), + relationalIdent(constraint), + relationalIdent(relation.TargetColumn), + relationalIdent(relation.TargetTable), + ) + if _, err := d.pool.Exec(ctx, alter); err != nil { + return err + } + } + } + return nil +} + +func (d *pgDatabase) migrateLegacyDocumentTable(ctx context.Context) error { + var legacyTable *string + if err := d.pool.QueryRow(ctx, "SELECT to_regclass('public.cms_documents')::text").Scan(&legacyTable); err != nil { + return err + } + if legacyTable == nil || strings.TrimSpace(*legacyTable) == "" { + return nil + } + + var archiveTable *string + if err := d.pool.QueryRow(ctx, "SELECT to_regclass('public.cms_documents_legacy_20260928')::text").Scan(&archiveTable); err != nil { + return err + } + if archiveTable != nil && strings.TrimSpace(*archiveTable) != "" { + return fmt.Errorf("legacy archive table already exists") + } + + rows, err := d.pool.Query(ctx, "SELECT collection, payload FROM cms_documents ORDER BY collection, id") + if err != nil { + return err + } + grouped := map[string][]Doc{} + for rows.Next() { + var collection string + var raw []byte + if err := rows.Scan(&collection, &raw); err != nil { + rows.Close() + return err + } + if _, ok := relationalResources[collection]; !ok { + rows.Close() + return fmt.Errorf("legacy collection %q has no relational mapping", collection) + } + var doc Doc + if err := json.Unmarshal(raw, &doc); err != nil { + rows.Close() + return fmt.Errorf("decode %s legacy row: %w", collection, err) + } + grouped[collection] = append(grouped[collection], doc) + } + if err := rows.Err(); err != nil { + rows.Close() + return err + } + rows.Close() + + tx, err := d.pool.Begin(ctx) + if err != nil { + return err + } + defer tx.Rollback(ctx) + if _, err := tx.Exec(ctx, "SET CONSTRAINTS ALL DEFERRED"); err != nil { + return err + } + + names := make([]string, 0, len(grouped)) + for name := range grouped { + names = append(names, name) + } + sort.SliceStable(names, func(i, j int) bool { + return relationalTableRank(names[i]) < relationalTableRank(names[j]) + }) + for _, name := range names { + collection := &pgCollection{db: d, spec: relationalResources[name]} + for _, doc := range grouped[name] { + if err := collection.writeDocument(ctx, tx, doc, true); err != nil { + return fmt.Errorf("%s row %v: %w", name, doc["id"], err) + } + } + } + + if _, err := tx.Exec(ctx, `ALTER TABLE "cms_documents" RENAME TO "cms_documents_legacy_20260928"`); err != nil { + return err + } + return tx.Commit(ctx) +} + +func (d *pgDatabase) ensureRelationalTable(ctx context.Context, spec relationalResourceSpec, _ bool) error { + columns := []string{relationalIdent("id") + " TEXT PRIMARY KEY"} + for _, field := range spec.columnFields() { + if field == "id" { + continue + } + column := relationalColumnName(field) + sqlType := relationalSQLType(spec, field) + definition := fmt.Sprintf("%s %s", relationalIdent(column), sqlType) + if field == "createdAt" || field == "updatedAt" { + definition += " NOT NULL DEFAULT 0" + } + columns = append(columns, definition) + } + query := fmt.Sprintf("CREATE TABLE IF NOT EXISTS %s (%s)", relationalIdent(spec.Table), strings.Join(columns, ", ")) + if _, err := d.pool.Exec(ctx, query); err != nil { + return err + } + + for _, field := range []string{"tenantId", "updatedAt", "status", "slug", "userId", "courseId", "communityId", "cohortId"} { + if !spec.hasColumn(field) { + continue + } + column := relationalColumnName(field) + indexName := fmt.Sprintf("%s_%s_idx", spec.Table, column) + if _, err := d.pool.Exec(ctx, fmt.Sprintf("CREATE INDEX IF NOT EXISTS %s ON %s (%s)", relationalIdent(indexName), relationalIdent(spec.Table), relationalIdent(column))); err != nil { + return err + } + } + return nil +} + +func (d *pgDatabase) ensureRelationalReferences(ctx context.Context, spec relationalResourceSpec) error { + fields := make([]string, 0, len(spec.References)) + for field := range spec.References { + fields = append(fields, field) + } + sort.Strings(fields) + + for _, field := range fields { + if !spec.hasField(field) { + continue + } + reference := spec.References[field] + column := relationalColumnName(field) + constraint := fmt.Sprintf("%s_%s_fk", spec.Table, column) + query := fmt.Sprintf(`DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = '%s' + ) THEN + ALTER TABLE %s ADD CONSTRAINT %s FOREIGN KEY (%s) + REFERENCES %s(id) ON DELETE %s DEFERRABLE INITIALLY DEFERRED; + END IF; +END $$;`, constraint, relationalIdent(spec.Table), relationalIdent(constraint), relationalIdent(column), relationalIdent(reference.Table), reference.OnDelete) + if _, err := d.pool.Exec(ctx, query); err != nil { + return err + } + } + return nil +} + +func relationalTableRank(name string) int { + switch name { + case "tenants": + return 0 + case "users", "blog_categories", "blog_tags", "blog_authors", "forms", "scorm_courses", "assets", "communities", "live_courses", "notification_templates", "billing_customers", "keycloak_themes": + return 10 + case "community_channels", "live_course_cohorts", "notification_deliveries": + return 20 + case "live_course_sessions", "live_course_enrollments", "community_posts", "notification_queue": + return 30 + case "live_course_attendance", "community_replies", "notification_delivery_attempts": + return 40 + default: + return 50 + } +} + +func uniqueStrings(values []string) []string { + seen := map[string]struct{}{} + out := make([]string, 0, len(values)) + for _, value := range values { + if _, ok := seen[value]; ok { + continue + } + seen[value] = struct{}{} + out = append(out, value) + } + return out +} + + +func relationalIdent(value string) string { + return `"` + strings.ReplaceAll(value, `"`, `""`) + `"` +} diff --git a/Backend/cmd/api/routes.go b/Backend/cmd/api/routes.go new file mode 100644 index 0000000..254c9df --- /dev/null +++ b/Backend/cmd/api/routes.go @@ -0,0 +1,402 @@ +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, + }) +} diff --git a/Backend/cmd/api/routes_api_admin.go b/Backend/cmd/api/routes_api_admin.go new file mode 100644 index 0000000..4d5fc3c --- /dev/null +++ b/Backend/cmd/api/routes_api_admin.go @@ -0,0 +1,26 @@ +package main + +import "github.com/go-chi/chi/v5" + +func (a *app) registerAdminRoutes(r chi.Router) { + r.Route("/admin/settings", func(r chi.Router) { + r.Use(a.settingsAdminMiddleware) + r.Get("/", a.getAdminSettings) + r.Patch("/", a.patchAdminSettings) + }) + + r.Route("/admin/audit", func(r chi.Router) { + r.Use(a.auditAdminMiddleware) + r.Get("/", a.listAdminAuditEvents) + r.Get("/{id}", a.getAdminAuditEvent) + }) + + r.Route("/platform-admin/tenants", func(r chi.Router) { + r.Use(a.platformAdminMiddleware) + r.Get("/", a.listTenants) + r.Post("/", a.createTenant) + r.Get("/{id}", a.getTenant) + r.Patch("/{id}", a.patchTenant) + r.Delete("/{id}", a.deleteTenant) + }) +} diff --git a/Backend/cmd/api/routes_api_assets.go b/Backend/cmd/api/routes_api_assets.go new file mode 100644 index 0000000..2acfde0 --- /dev/null +++ b/Backend/cmd/api/routes_api_assets.go @@ -0,0 +1,20 @@ +package main + +import "github.com/go-chi/chi/v5" + +func (a *app) registerAssetRoutes(r chi.Router) { + r.Route("/assets", func(r chi.Router) { + r.Use(a.assetsAdminMiddleware) + r.Get("/", a.listAssets) + r.Post("/", a.createAsset) + r.Get("/{id}", a.getAsset) + r.Patch("/{id}", a.patchAsset) + r.Delete("/{id}", a.deleteAsset) + r.Get("/{id}/access-events", a.listAssetAccessEvents) + r.Get("/{id}/claims", a.listAssetClaims) + r.Post("/{id}/quarantine", a.quarantineAsset) + r.Post("/{id}/disable", a.disableAsset) + r.Post("/{id}/restore", a.restoreAsset) + r.Post("/{id}/encrypt", a.encryptAsset) + }) +} diff --git a/Backend/cmd/api/routes_api_blog.go b/Backend/cmd/api/routes_api_blog.go new file mode 100644 index 0000000..37c3236 --- /dev/null +++ b/Backend/cmd/api/routes_api_blog.go @@ -0,0 +1,32 @@ +package main + +import "github.com/go-chi/chi/v5" + +func (a *app) registerBlogRoutes(r chi.Router) { + r.Route("/blog", func(r chi.Router) { + r.Use(a.blogEditorMiddleware) + r.Get("/posts", a.listBlogPosts) + r.Post("/posts", a.createBlogPost) + r.Get("/posts/{id}", a.getBlogPost) + r.Patch("/posts/{id}", a.patchBlogPost) + r.Delete("/posts/{id}", a.deleteBlogPost) + + r.Get("/categories", a.listBlogCategories) + r.Post("/categories", a.createBlogCategory) + r.Get("/categories/{id}", a.getBlogCategory) + r.Patch("/categories/{id}", a.patchBlogCategory) + r.Delete("/categories/{id}", a.deleteBlogCategory) + + r.Get("/tags", a.listBlogTags) + r.Post("/tags", a.createBlogTag) + r.Get("/tags/{id}", a.getBlogTag) + r.Patch("/tags/{id}", a.patchBlogTag) + r.Delete("/tags/{id}", a.deleteBlogTag) + + r.Get("/authors", a.listBlogAuthors) + r.Post("/authors", a.createBlogAuthor) + r.Get("/authors/{id}", a.getBlogAuthor) + r.Patch("/authors/{id}", a.patchBlogAuthor) + r.Delete("/authors/{id}", a.deleteBlogAuthor) + }) +} diff --git a/Backend/cmd/api/routes_api_communities.go b/Backend/cmd/api/routes_api_communities.go new file mode 100644 index 0000000..eb1331e --- /dev/null +++ b/Backend/cmd/api/routes_api_communities.go @@ -0,0 +1,22 @@ +package main + +import "github.com/go-chi/chi/v5" + +func (a *app) registerCommunityRoutes(r chi.Router) { + r.Route("/communities", func(r chi.Router) { + r.Use(a.communitiesAdminMiddleware) + r.Get("/", a.listCommunities) + r.Post("/", a.createCommunity) + r.Get("/{id}", a.getCommunity) + r.Patch("/{id}", a.patchCommunity) + r.Delete("/{id}", a.deleteCommunity) + r.Get("/{id}/channels", a.listCommunityChannels) + r.Post("/{id}/channels", a.createCommunityChannel) + r.Get("/{id}/channels/{channelId}", a.getCommunityChannel) + r.Patch("/{id}/channels/{channelId}", a.patchCommunityChannel) + r.Delete("/{id}/channels/{channelId}", a.deleteCommunityChannel) + r.Get("/{id}/memberships", a.listCommunityMemberships) + r.Post("/{id}/memberships", a.createCommunityMembership) + r.Patch("/{id}/memberships/{membershipId}", a.patchCommunityMembership) + }) +} diff --git a/Backend/cmd/api/routes_api_core.go b/Backend/cmd/api/routes_api_core.go new file mode 100644 index 0000000..1c68f06 --- /dev/null +++ b/Backend/cmd/api/routes_api_core.go @@ -0,0 +1,64 @@ +package main + +import "github.com/go-chi/chi/v5" + +func (a *app) registerCoreAPIRoutes(r chi.Router) { + r.Route("/mcp", func(r chi.Router) { + r.Use(a.authoringRoleMiddleware) + r.Post("/", a.handleMCP) + }) + r.Route("/authoring-runs", func(r chi.Router) { + r.Use(a.authoringRoleMiddleware) + r.Post("/", a.createAuthoringRun) + r.Get("/{id}", a.getAuthoringRun) + r.Post("/{id}/theme", a.proposeAuthoringThemeHTTP) + r.Post("/{id}/theme/validate", a.validateAuthoringThemeHTTP) + r.Post("/{id}/site", a.proposeAuthoringSiteStructureHTTP) + r.Put("/{id}/site/pages", a.upsertAuthoringSitePageHTTP) + r.Post("/{id}/site/validate", a.validateAuthoringSiteHTTP) + r.Delete("/{id}", a.deleteAuthoringRun) + }) + + r.Get("/pages", a.listPages) + r.Post("/pages", a.createPage) + r.Get("/pages/{id}", a.getPage) + r.Patch("/pages/{id}", a.patchPage) + r.Delete("/pages/{id}", a.deletePage) + + r.Get("/datasources", a.listDataSources) + r.Post("/datasources", a.createDataSource) + r.Get("/datasources/{id}", a.getDataSource) + r.Patch("/datasources/{id}", a.patchDataSource) + r.Delete("/datasources/{id}", a.deleteDataSource) + + r.Get("/scorm", a.listScormCourses) + r.Post("/scorm", a.createScormCourse) + r.Get("/scorm/{id}", a.getScormCourse) + r.Patch("/scorm/{id}", a.patchScormCourse) + r.Delete("/scorm/{id}", a.deleteScormCourse) + r.Post("/scorm/{id}/export", a.exportScormCourse) + r.Get("/scorm/{id}/exports", a.listScormExports) + + r.Get("/users", a.listUsers) + r.Post("/users", a.createUser) + r.Get("/users/{id}", a.getUser) + r.Patch("/users/{id}", a.patchUser) + r.Delete("/users/{id}", a.deleteUser) + + r.Get("/enrollments", a.listEnrollments) + r.Post("/enrollments", a.createEnrollment) + r.Get("/enrollments/{id}", a.getEnrollment) + r.Patch("/enrollments/{id}", a.patchEnrollment) + r.Delete("/enrollments/{id}", a.deleteEnrollment) + + r.Get("/webhooks", a.listWebhooks) + r.Post("/webhooks", a.createWebhook) + r.Get("/webhooks/{id}", a.getWebhook) + r.Patch("/webhooks/{id}", a.patchWebhook) + r.Delete("/webhooks/{id}", a.deleteWebhook) + + r.Get("/analytics", a.analyticsHandler) + r.Post("/billing/checkout", a.createCheckoutSession) + r.Get("/permissions/registry", a.listPermissionRegistry) + r.Get("/entitlements/me/{key}", a.checkMyEntitlement) +} diff --git a/Backend/cmd/api/routes_api_entitlements.go b/Backend/cmd/api/routes_api_entitlements.go new file mode 100644 index 0000000..0761003 --- /dev/null +++ b/Backend/cmd/api/routes_api_entitlements.go @@ -0,0 +1,15 @@ +package main + +import "github.com/go-chi/chi/v5" + +func (a *app) registerEntitlementsRoutes(r chi.Router) { + r.Route("/entitlements", func(r chi.Router) { + r.Use(a.entitlementsAdminMiddleware) + r.Get("/", a.listEntitlements) + r.Post("/", a.createEntitlement) + r.Get("/{id}", a.getEntitlement) + r.Patch("/{id}", a.patchEntitlement) + r.Delete("/{id}", a.deleteEntitlement) + r.Post("/{id}/revoke", a.revokeEntitlement) + }) +} diff --git a/Backend/cmd/api/routes_api_forms.go b/Backend/cmd/api/routes_api_forms.go new file mode 100644 index 0000000..6b4ab18 --- /dev/null +++ b/Backend/cmd/api/routes_api_forms.go @@ -0,0 +1,26 @@ +package main + +import "github.com/go-chi/chi/v5" + +func (a *app) registerFormsRoutes(r chi.Router) { + r.Route("/forms", func(r chi.Router) { + r.Use(a.formsAdminMiddleware) + r.Get("/", a.listForms) + r.Post("/", a.createForm) + r.Get("/{id}", a.getForm) + r.Patch("/{id}", a.patchForm) + r.Delete("/{id}", a.deleteForm) + r.Get("/{id}/submissions", a.listFormSubmissions) + }) +} + +func (a *app) registerSitemapMenuRoutes(r chi.Router) { + r.Route("/sitemap-menus", func(r chi.Router) { + r.Use(a.sitemapMenusAdminMiddleware) + r.Get("/", a.listSitemapMenus) + r.Post("/", a.createSitemapMenu) + r.Get("/{id}", a.getSitemapMenu) + r.Patch("/{id}", a.patchSitemapMenu) + r.Delete("/{id}", a.deleteSitemapMenu) + }) +} diff --git a/Backend/cmd/api/routes_api_keycloak_themes.go b/Backend/cmd/api/routes_api_keycloak_themes.go new file mode 100644 index 0000000..8994ec5 --- /dev/null +++ b/Backend/cmd/api/routes_api_keycloak_themes.go @@ -0,0 +1,18 @@ +package main + +import "github.com/go-chi/chi/v5" + +func (a *app) registerKeycloakThemeRoutes(r chi.Router) { + r.Route("/keycloak-themes", func(r chi.Router) { + r.Use(a.keycloakThemeReadMiddleware) + r.Get("/schema", a.getKeycloakThemeFormSchema) + r.Get("/", a.listKeycloakThemes) + r.Post("/", a.createKeycloakTheme) + r.Get("/{id}", a.getKeycloakTheme) + r.Patch("/{id}", a.patchKeycloakTheme) + r.Delete("/{id}", a.deleteKeycloakTheme) + r.Post("/{id}/preview", a.previewKeycloakTheme) + r.Post("/{id}/export", a.exportKeycloakTheme) + r.Post("/{id}/deploy", a.deployKeycloakTheme) + }) +} diff --git a/Backend/cmd/api/routes_api_learner.go b/Backend/cmd/api/routes_api_learner.go new file mode 100644 index 0000000..f0a320f --- /dev/null +++ b/Backend/cmd/api/routes_api_learner.go @@ -0,0 +1,27 @@ +package main + +import "github.com/go-chi/chi/v5" + +func (a *app) registerLearnerRoutes(r chi.Router) { + r.Route("/learner/live-courses", func(r chi.Router) { + r.Use(a.liveCourseLearnerMiddleware) + r.Get("/", a.listLearnerLiveCourses) + r.Get("/{id}", a.getLearnerLiveCourse) + r.Get("/{id}/sessions", a.listLearnerLiveCourseSessions) + r.Get("/{id}/sessions/{sessionId}", a.getLearnerLiveCourseSession) + r.Post("/{id}/sessions/{sessionId}/join", a.joinLearnerLiveCourseSession) + }) + + r.Route("/learner/communities", func(r chi.Router) { + r.Use(a.communityLearnerMiddleware) + r.Get("/", a.listLearnerCommunities) + r.Get("/{id}/channels", a.listLearnerCommunityChannels) + r.Post("/{id}/join", a.joinLearnerCommunity) + }) + + r.Route("/learner/scorm", func(r chi.Router) { + r.Use(a.scormLearnerMiddleware) + r.Get("/", a.listLearnerScormCourses) + r.Get("/{id}/launch", a.getLearnerScormLaunch) + }) +} diff --git a/Backend/cmd/api/routes_api_live_courses.go b/Backend/cmd/api/routes_api_live_courses.go new file mode 100644 index 0000000..a199096 --- /dev/null +++ b/Backend/cmd/api/routes_api_live_courses.go @@ -0,0 +1,34 @@ +package main + +import "github.com/go-chi/chi/v5" + +func (a *app) registerLiveCourseRoutes(r chi.Router) { + r.Route("/live-courses", func(r chi.Router) { + r.Use(a.liveCourseManagerMiddleware) + r.Get("/", a.listLiveCourses) + r.Post("/", a.createLiveCourse) + r.Get("/{id}", a.getLiveCourse) + r.Patch("/{id}", a.patchLiveCourse) + r.Delete("/{id}", a.deleteLiveCourse) + + r.Get("/{id}/cohorts", a.listLiveCourseCohorts) + r.Post("/{id}/cohorts", a.createLiveCourseCohort) + r.Get("/{id}/cohorts/{cohortId}", a.getLiveCourseCohort) + r.Patch("/{id}/cohorts/{cohortId}", a.patchLiveCourseCohort) + r.Delete("/{id}/cohorts/{cohortId}", a.deleteLiveCourseCohort) + + r.Get("/{id}/cohorts/{cohortId}/sessions", a.listLiveCourseSessions) + r.Post("/{id}/cohorts/{cohortId}/sessions", a.createLiveCourseSession) + r.Get("/{id}/cohorts/{cohortId}/sessions/{sessionId}", a.getLiveCourseSession) + r.Patch("/{id}/cohorts/{cohortId}/sessions/{sessionId}", a.patchLiveCourseSession) + r.Delete("/{id}/cohorts/{cohortId}/sessions/{sessionId}", a.deleteLiveCourseSession) + + r.Get("/{id}/cohorts/{cohortId}/enrollments", a.listLiveCourseEnrollments) + r.Post("/{id}/cohorts/{cohortId}/enrollments", a.createLiveCourseEnrollment) + r.Patch("/{id}/cohorts/{cohortId}/enrollments/{enrollmentId}", a.patchLiveCourseEnrollment) + + r.Get("/{id}/cohorts/{cohortId}/sessions/{sessionId}/attendance", a.listLiveCourseAttendance) + r.Post("/{id}/cohorts/{cohortId}/sessions/{sessionId}/attendance", a.createLiveCourseAttendance) + r.Patch("/{id}/cohorts/{cohortId}/sessions/{sessionId}/attendance/{attendanceId}", a.patchLiveCourseAttendance) + }) +} diff --git a/Backend/cmd/api/routes_api_notifications.go b/Backend/cmd/api/routes_api_notifications.go new file mode 100644 index 0000000..e3e121a --- /dev/null +++ b/Backend/cmd/api/routes_api_notifications.go @@ -0,0 +1,26 @@ +package main + +import "github.com/go-chi/chi/v5" + +func (a *app) registerNotificationRoutes(r chi.Router) { + r.Route("/notifications", func(r chi.Router) { + r.Route("/templates", func(r chi.Router) { + r.Use(a.notificationsAdminMiddleware) + r.Get("/", a.listNotificationTemplates) + r.Post("/", a.createNotificationTemplate) + r.Get("/{id}", a.getNotificationTemplate) + r.Patch("/{id}", a.patchNotificationTemplate) + r.Delete("/{id}", a.deleteNotificationTemplate) + }) + + r.Route("/deliveries", func(r chi.Router) { + r.Use(a.notificationsAdminMiddleware) + r.Get("/", a.listNotificationDeliveries) + r.Post("/", a.createNotificationDelivery) + }) + + r.Get("/preferences", a.listNotificationPreferences) + r.Post("/preferences", a.createNotificationPreference) + r.Patch("/preferences/{id}", a.patchNotificationPreference) + }) +} diff --git a/Backend/cmd/api/routes_auth.go b/Backend/cmd/api/routes_auth.go new file mode 100644 index 0000000..26484d8 --- /dev/null +++ b/Backend/cmd/api/routes_auth.go @@ -0,0 +1,12 @@ +package main + +import "github.com/go-chi/chi/v5" + +func (a *app) registerAuthRoutes(r chi.Router) { + r.Route("/auth", func(r chi.Router) { + r.Post("/login", a.loginHandler) + r.Post("/refresh", a.refreshHandler) + r.Post("/logout", a.logoutHandler) + r.With(a.authMiddleware).Get("/me", a.meHandler) + }) +} diff --git a/Backend/cmd/api/routes_public.go b/Backend/cmd/api/routes_public.go new file mode 100644 index 0000000..4bf2df3 --- /dev/null +++ b/Backend/cmd/api/routes_public.go @@ -0,0 +1,31 @@ +package main + +import "github.com/go-chi/chi/v5" + +func (a *app) registerPublicRoutes(r chi.Router) { + r.Get("/.well-known/oauth-protected-resource", a.mcpProtectedResourceMetadata) + r.Get("/sitemap.xml", a.sitemapHandler) + r.Get("/robots.txt", a.robotsHandler) + + r.Route("/api/public/forms", func(r chi.Router) { + r.Get("/{id}", a.getPublicForm) + r.Post("/{id}/submissions", a.createPublicFormSubmission) + }) + + r.Get("/api/public/pages/{slug}", a.getPublicPageBySlug) + + r.Route("/api/public/blog", func(r chi.Router) { + r.Get("/posts", a.listPublicBlogPosts) + r.Get("/posts/{slug}", a.getPublicBlogPostBySlug) + r.Get("/categories", a.listPublicBlogCategories) + r.Get("/tags", a.listPublicBlogTags) + }) + + r.Route("/api/public/live-courses", func(r chi.Router) { + r.Get("/", a.listPublicLiveCourses) + r.Get("/{slug}", a.getPublicLiveCourseBySlug) + r.Get("/{slug}/cohorts", a.listPublicLiveCourseCohortsBySlug) + }) + + r.Post("/api/public/billing/stripe/webhook", a.handleStripeWebhook) +} diff --git a/Backend/cmd/api/scorm_learner.go b/Backend/cmd/api/scorm_learner.go new file mode 100644 index 0000000..6db6e9d --- /dev/null +++ b/Backend/cmd/api/scorm_learner.go @@ -0,0 +1,284 @@ +package main + +import ( + "errors" + "net/http" + "strings" + + "github.com/go-chi/chi/v5" +) + +func (a *app) scormLearnerMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + claims, _ := r.Context().Value(userClaimsKey).(*accessClaims) + if claims == nil { + writeError(w, http.StatusUnauthorized, "no authenticated user") + return + } + if !hasRole(claims, "learner") && !hasRole(claims, "admin") && !hasRole(claims, "instructor") { + writeError(w, http.StatusForbidden, "insufficient role for learner scorm access") + return + } + next.ServeHTTP(w, r) + }) +} + +func (a *app) listLearnerScormCourses(w http.ResponseWriter, r *http.Request) { + if a.db == nil { + writeError(w, http.StatusServiceUnavailable, "database not configured") + return + } + + userID, ok := a.learnerSubjectFromContext(r) + if !ok { + writeError(w, http.StatusUnauthorized, "missing learner subject") + return + } + + enrollments, err := a.list("enrollments", Doc{"userId": userID}, Sort{{Key: "updatedAt", Value: -1}, {Key: "createdAt", Value: -1}}) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to list learner enrollments") + return + } + + courseIDs := make(map[string]struct{}) + filteredEnrollments := make([]Doc, 0, len(enrollments)) + for _, enrollment := range enrollments { + courseID, _ := asString(enrollment["courseId"]) + if courseID == "" { + continue + } + courseType, _ := asString(enrollment["courseType"]) + if courseType != "" && !strings.EqualFold(courseType, "scorm") { + continue + } + filteredEnrollments = append(filteredEnrollments, enrollment) + courseIDs[courseID] = struct{}{} + } + + if len(courseIDs) == 0 { + writeJSON(w, http.StatusOK, []Doc{}) + return + } + + courses, err := a.list("scorm_courses", Doc{"id": Doc{"$in": mapKeys(courseIDs)}}, Sort{{Key: "updatedAt", Value: -1}}) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to load scorm courses") + return + } + courseByID := indexByID(courses) + + exports, err := a.list("scorm_exports", Doc{"courseId": Doc{"$in": mapKeys(courseIDs)}}, Sort{{Key: "createdAt", Value: -1}}) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to load scorm exports") + return + } + exportsByCourse := make(map[string][]Doc) + for _, exportDoc := range exports { + courseID, _ := asString(exportDoc["courseId"]) + if courseID == "" { + continue + } + exportsByCourse[courseID] = append(exportsByCourse[courseID], exportDoc) + } + + items := make([]Doc, 0, len(filteredEnrollments)) + seenCourse := make(map[string]struct{}) + for _, enrollment := range filteredEnrollments { + courseID, _ := asString(enrollment["courseId"]) + if courseID == "" { + continue + } + if _, exists := seenCourse[courseID]; exists { + continue + } + course := courseByID[courseID] + if course == nil { + continue + } + + hasEntitlement, entitlementReason, entitlementMessage, err := a.learnerEntitlementDecision(r, userID, resolveEntitlementKey(course, nil)) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to evaluate learner entitlement") + return + } + + seenCourse[courseID] = struct{}{} + items = append(items, Doc{ + "course": course, + "enrollment": enrollment, + "launchState": evaluateLearnerScormLaunch(course, enrollment, exportsByCourse[courseID], hasEntitlement, entitlementReason, entitlementMessage), + }) + } + + writeJSON(w, http.StatusOK, items) +} + +func (a *app) getLearnerScormLaunch(w http.ResponseWriter, r *http.Request) { + if a.db == nil { + writeError(w, http.StatusServiceUnavailable, "database not configured") + return + } + + userID, ok := a.learnerSubjectFromContext(r) + if !ok { + writeError(w, http.StatusUnauthorized, "missing learner subject") + return + } + + courseID := strings.TrimSpace(chi.URLParam(r, "id")) + if courseID == "" { + writeError(w, http.StatusBadRequest, "scorm course id is required") + return + } + + course, err := a.getByID("scorm_courses", courseID) + if err != nil { + if errors.Is(err, ErrNotFound) { + writeError(w, http.StatusNotFound, "scorm course not found") + return + } + writeError(w, http.StatusInternalServerError, "failed to load scorm course") + return + } + + enrollments, err := a.list("enrollments", Doc{"userId": userID, "courseId": courseID}, Sort{{Key: "updatedAt", Value: -1}, {Key: "createdAt", Value: -1}}) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to load learner enrollment") + return + } + enrollment := firstDocument(enrollments) + + exports, err := a.list("scorm_exports", Doc{"courseId": courseID}, Sort{{Key: "createdAt", Value: -1}}) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to load scorm exports") + return + } + + hasEntitlement, entitlementReason, entitlementMessage, err := a.learnerEntitlementDecision(r, userID, resolveEntitlementKey(course, nil)) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to evaluate learner entitlement") + return + } + + launchState := evaluateLearnerScormLaunch(course, enrollment, exports, hasEntitlement, entitlementReason, entitlementMessage) + writeJSON(w, http.StatusOK, Doc{ + "course": course, + "enrollment": enrollment, + "launchState": launchState, + }) +} + +func evaluateLearnerScormLaunch(course, enrollment Doc, exports []Doc, entitlementAllowed bool, entitlementReason, entitlementMessage string) Doc { + if course == nil { + return Doc{ + "allowed": false, + "state": "locked", + "reason": "missing_course", + "message": "SCORM course was not found.", + "progressNote": "Progress sync is unavailable because this SCORM course could not be loaded.", + } + } + + if enrollment == nil { + return Doc{ + "allowed": false, + "state": "locked", + "reason": "missing_enrollment", + "message": "You are not currently enrolled in this SCORM course.", + "progressNote": "Resume is unavailable until enrollment is active.", + } + } + + status, _ := asString(enrollment["status"]) + if !isLearnerScormEnrollmentActive(status) { + return Doc{ + "allowed": false, + "state": "locked", + "reason": "inactive_enrollment", + "message": "Your enrollment is not active for SCORM launch.", + "progressNote": "Resume is unavailable while enrollment is inactive.", + } + } + + if !entitlementAllowed { + if strings.TrimSpace(entitlementReason) == "" { + entitlementReason = "missing_entitlement" + } + if strings.TrimSpace(entitlementMessage) == "" { + entitlementMessage = "Your entitlement is missing or inactive for this SCORM course." + } + return Doc{ + "allowed": false, + "state": "locked", + "reason": entitlementReason, + "message": entitlementMessage, + "progressNote": "Launch is unavailable until the required entitlement is active.", + } + } + + if !hasScormCoursePackage(course) { + return Doc{ + "allowed": false, + "state": "missing_package", + "reason": "missing_package", + "message": "This SCORM course has no package content to launch yet.", + "progressNote": "Resume will be available after content and runtime tracking are connected.", + } + } + + if len(exports) == 0 { + return Doc{ + "allowed": false, + "state": "export_not_ready", + "reason": "export_not_ready", + "message": "A SCORM export has not been generated for this course yet.", + "progressNote": "Resume placeholders remain visible after an export is available.", + } + } + + courseID, _ := asString(course["id"]) + exportID, _ := asString(exports[0]["id"]) + launchURL := "/api/scorm/" + courseID + "/exports" + if exportID != "" { + launchURL += "/" + exportID + } + + resumeEligible := strings.EqualFold(status, "in_progress") || strings.EqualFold(status, "completed") + progressNote := "Resume will use your last saved SCORM location once runtime tracking is fully integrated." + if !resumeEligible { + progressNote = "Progress tracking is not yet connected; start now and a resume point will appear when available." + } + + return Doc{ + "allowed": true, + "state": "ready", + "reason": "", + "message": "SCORM package is ready to launch.", + "launchUrl": launchURL, + "resumeAvailable": resumeEligible, + "progressNote": progressNote, + } +} + +func isLearnerScormEnrollmentActive(status string) bool { + normalized := strings.TrimSpace(strings.ToLower(status)) + switch normalized { + case "", "not_started", "in_progress", "completed", "failed", "enrolled": + return true + default: + return false + } +} + +func hasScormCoursePackage(course Doc) bool { + raw := course["scos"] + switch value := raw.(type) { + case []any: + return len(value) > 0 + case List: + return len(value) > 0 + default: + return false + } +} diff --git a/Backend/cmd/api/seo_public.go b/Backend/cmd/api/seo_public.go new file mode 100644 index 0000000..2114c6c --- /dev/null +++ b/Backend/cmd/api/seo_public.go @@ -0,0 +1,307 @@ +package main + +import ( + "encoding/xml" + "net/http" + "net/url" + "strconv" + "strings" + "time" +) + +type sitemapEntry struct { + Loc string + LastMod string +} + +type sitemapXML struct { + XMLName xml.Name `xml:"urlset"` + XMLNS string `xml:"xmlns,attr"` + URLs []sitemapURL `xml:"url"` +} + +type sitemapURL struct { + Loc string `xml:"loc"` + LastMod string `xml:"lastmod,omitempty"` +} + +func (a *app) sitemapHandler(w http.ResponseWriter, r *http.Request) { + if a.db == nil { + writeError(w, http.StatusServiceUnavailable, "database not configured") + return + } + + pages, err := a.list("pages", Doc{}, Sort{{Key: "updatedAt", Value: -1}}) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to build sitemap") + return + } + + entries := collectSitemapEntries(pages, requestOrigin(r)) + body, err := renderSitemapXML(entries) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to render sitemap") + return + } + + w.Header().Set("Content-Type", "application/xml; charset=utf-8") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(body)) +} + +func (a *app) robotsHandler(w http.ResponseWriter, r *http.Request) { + body := renderRobotsTXT(requestOrigin(r)) + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(body)) +} + +func collectSitemapEntries(pages []Doc, baseURL string) []sitemapEntry { + seen := make(map[string]struct{}, len(pages)) + entries := make([]sitemapEntry, 0, len(pages)) + + for _, page := range pages { + if !isPublishedPublicPage(page) { + continue + } + + loc, ok := pagePublicURL(page, baseURL) + if !ok { + continue + } + if _, exists := seen[loc]; exists { + continue + } + seen[loc] = struct{}{} + + entries = append(entries, sitemapEntry{ + Loc: loc, + LastMod: pageLastMod(page), + }) + } + + return entries +} + +func isPublishedPublicPage(page Doc) bool { + published := false + now := time.Now().UTC() + + if status, ok := asString(page["status"]); ok && strings.EqualFold(status, "published") { + published = true + } + if value, ok := asBool(page["published"]); ok && value { + published = true + } + if value, ok := asBool(page["isPublished"]); ok && value { + published = true + } + if publishedAt, ok := asTime(page["publishedAt"]); ok { + if publishedAt.UTC().After(now) { + return false + } + published = true + } + + if !published { + return false + } + + if value, ok := asBool(page["private"]); ok && value { + return false + } + if value, ok := asBool(page["isPrivate"]); ok && value { + return false + } + if visibility, ok := asString(page["visibility"]); ok && strings.EqualFold(visibility, "private") { + return false + } + if access, ok := asString(page["access"]); ok && strings.EqualFold(access, "private") { + return false + } + + if !isIndexablePage(page) { + return false + } + + slug, ok := asString(page["slug"]) + return ok && strings.TrimSpace(slug) != "" +} + +func isIndexablePage(page Doc) bool { + seo, ok := asMap(page["seo"]) + if !ok { + return true + } + + robotsRaw, exists := seo["robots"] + if !exists || robotsRaw == nil { + return true + } + + robots, ok := asMap(robotsRaw) + if !ok { + return true + } + + if index, exists := asBool(robots["index"]); exists && !index { + return false + } + + return true +} + +func pagePublicURL(page Doc, baseURL string) (string, bool) { + seo, _ := asMap(page["seo"]) + if canonical, ok := asString(seo["canonicalUrl"]); ok && canonical != "" && isAbsoluteHTTPURL(canonical) { + return canonical, true + } + + slug, ok := asString(page["slug"]) + if !ok { + return "", false + } + slug = strings.Trim(strings.TrimSpace(slug), "/") + if slug == "" { + return "", false + } + + return strings.TrimRight(baseURL, "/") + "/pages/" + slug, true +} + +func pageLastMod(page Doc) string { + raw, ok := page["updatedAt"] + if !ok { + return "" + } + + if t, ok := asTime(raw); ok { + return t.UTC().Format(time.RFC3339) + } + + return "" +} + +func renderSitemapXML(entries []sitemapEntry) (string, error) { + xmlDoc := sitemapXML{XMLNS: "http://www.sitemaps.org/schemas/sitemap/0.9"} + xmlDoc.URLs = make([]sitemapURL, 0, len(entries)) + for _, entry := range entries { + xmlDoc.URLs = append(xmlDoc.URLs, sitemapURL{Loc: entry.Loc, LastMod: entry.LastMod}) + } + + body, err := xml.MarshalIndent(xmlDoc, "", " ") + if err != nil { + return "", err + } + + return xml.Header + string(body), nil +} + +func renderRobotsTXT(baseURL string) string { + base := strings.TrimRight(baseURL, "/") + return "User-agent: *\nAllow: /\nSitemap: " + base + "/sitemap.xml\n" +} + +func requestOrigin(r *http.Request) string { + scheme := r.Header.Get("X-Forwarded-Proto") + if scheme == "" { + if r.TLS != nil { + scheme = "https" + } else { + scheme = "http" + } + } + + host := r.Header.Get("X-Forwarded-Host") + if host == "" { + host = r.Host + } + + if host == "" { + host = "localhost:8080" + } + + return scheme + "://" + host +} + +func isAbsoluteHTTPURL(value string) bool { + u, err := url.Parse(strings.TrimSpace(value)) + if err != nil { + return false + } + if u.Host == "" { + return false + } + return u.Scheme == "http" || u.Scheme == "https" +} + +func asMap(raw any) (map[string]any, bool) { + switch value := raw.(type) { + case map[string]any: + return value, true + case Doc: + return map[string]any(value), true + default: + return nil, false + } +} + +func asString(raw any) (string, bool) { + value, ok := raw.(string) + if !ok { + return "", false + } + value = strings.TrimSpace(value) + if value == "" { + return "", false + } + return value, true +} + +func asBool(raw any) (bool, bool) { + value, ok := raw.(bool) + return value, ok +} + +func asTime(raw any) (time.Time, bool) { + switch value := raw.(type) { + case time.Time: + if value.IsZero() { + return time.Time{}, false + } + return value, true + case int: + if value <= 0 { + return time.Time{}, false + } + return time.UnixMilli(int64(value)), true + case int32: + if value <= 0 { + return time.Time{}, false + } + return time.UnixMilli(int64(value)), true + case int64: + if value <= 0 { + return time.Time{}, false + } + return time.UnixMilli(value), true + case float64: + if value <= 0 { + return time.Time{}, false + } + return time.UnixMilli(int64(value)), true + case string: + trimmed := strings.TrimSpace(value) + if trimmed == "" { + return time.Time{}, false + } + if parsed, err := strconv.ParseInt(trimmed, 10, 64); err == nil && parsed > 0 { + return time.UnixMilli(parsed), true + } + if parsed, err := time.Parse(time.RFC3339, trimmed); err == nil && !parsed.IsZero() { + return parsed, true + } + } + + return time.Time{}, false +} \ No newline at end of file diff --git a/Backend/cmd/api/sitemap_menus.go b/Backend/cmd/api/sitemap_menus.go new file mode 100644 index 0000000..6f2668f --- /dev/null +++ b/Backend/cmd/api/sitemap_menus.go @@ -0,0 +1,307 @@ +package main + +import ( + "context" + "errors" + "net/http" + "strings" + "time" + + "github.com/go-chi/chi/v5" + "github.com/google/uuid" +) + +const ( + sitemapTargetPage = "page" + sitemapTargetPost = "post" + sitemapTargetCourse = "course" +) + +var allowedSitemapTargetTypes = map[string]string{ + sitemapTargetPage: "pages", + sitemapTargetPost: "blog_posts", + sitemapTargetCourse: "live_courses", +} + +func (a *app) sitemapMenusAdminMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + claims, _ := r.Context().Value(userClaimsKey).(*accessClaims) + if claims == nil { + writeError(w, http.StatusUnauthorized, "no authenticated user") + return + } + if !hasRole(claims, "admin") { + writeError(w, http.StatusForbidden, "insufficient role for sitemap menu management") + return + } + next.ServeHTTP(w, r) + }) +} + +func (a *app) listSitemapMenus(w http.ResponseWriter, r *http.Request) { + a.listCollection("sitemap_menus", w) +} + +func (a *app) createSitemapMenu(w http.ResponseWriter, r *http.Request) { + if a.db == nil { + writeError(w, http.StatusServiceUnavailable, "database not configured") + return + } + + var payload map[string]any + if err := decodeJSON(r, &payload); err != nil { + writeError(w, http.StatusBadRequest, "invalid payload") + return + } + + doc, err := prepareSitemapMenuPayload(Doc(payload), false) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + + items, _, _ := normalizeSitemapMenuItems(doc["items"]) + if err := a.validateSitemapMenuTargets(items); err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + + doc, err = a.create("sitemap_menus", doc) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to create resource") + return + } + writeJSON(w, http.StatusCreated, doc) +} + +func (a *app) getSitemapMenu(w http.ResponseWriter, r *http.Request) { + a.getCollection("sitemap_menus", w, r) +} + +func (a *app) patchSitemapMenu(w http.ResponseWriter, r *http.Request) { + if a.db == nil { + writeError(w, http.StatusServiceUnavailable, "database not configured") + return + } + + 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 + } + + doc, err := prepareSitemapMenuPayload(Doc(patch), true) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + + if rawItems, hasItems := doc["items"]; hasItems { + items, _, err := normalizeSitemapMenuItems(rawItems) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + if err := a.validateSitemapMenuTargets(items); err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + } + + doc, err = a.patch("sitemap_menus", id, doc) + 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) deleteSitemapMenu(w http.ResponseWriter, r *http.Request) { + a.deleteCollection("sitemap_menus", w, r) +} + +func prepareSitemapMenuPayload(payload Doc, partial bool) (Doc, error) { + if payload == nil { + payload = Doc{} + } + + name, hasName, err := getOptionalString(payload, "name") + if err != nil { + return nil, errors.New("name must be a string") + } + if !partial && (!hasName || name == "") { + return nil, errors.New("name is required") + } + if hasName { + payload["name"] = name + } + + description, hasDescription, err := getOptionalString(payload, "description") + if err != nil { + return nil, errors.New("description must be a string") + } + if hasDescription { + payload["description"] = description + } + + items, hasItems, err := normalizeSitemapMenuItems(payload["items"]) + if err != nil { + return nil, err + } + if !partial && !hasItems { + payload["items"] = []Doc{} + } else if hasItems { + payload["items"] = items + } + + return payload, nil +} + +func normalizeSitemapMenuItems(raw any) ([]Doc, bool, error) { + if raw == nil { + return nil, false, nil + } + + var values []any + switch source := raw.(type) { + case []any: + values = source + case List: + values = []any(source) + case []Doc: + values = make([]any, 0, len(source)) + for _, item := range source { + values = append(values, item) + } + default: + return nil, false, errors.New("items must be an array") + } + + normalized := make([]Doc, 0, len(values)) + for _, item := range values { + itemMap, ok := item.(map[string]any) + if !ok { + bsonItem, docOK := item.(Doc) + if !docOK { + return nil, false, errors.New("menu items must be objects") + } + itemMap = map[string]any(bsonItem) + } + + labelRaw, hasLabel := itemMap["label"] + if !hasLabel { + return nil, false, errors.New("menu item label is required") + } + label, ok := labelRaw.(string) + if !ok || strings.TrimSpace(label) == "" { + return nil, false, errors.New("menu item label must be a string") + } + + targetTypeRaw, hasTargetType := itemMap["targetType"] + if !hasTargetType { + return nil, false, errors.New("menu item targetType is required") + } + targetType, ok := targetTypeRaw.(string) + if !ok { + return nil, false, errors.New("menu item targetType must be a string") + } + targetType = strings.ToLower(strings.TrimSpace(targetType)) + if _, allowed := allowedSitemapTargetTypes[targetType]; !allowed { + return nil, false, errors.New("menu item targetType is invalid") + } + + targetIDRaw, hasTargetID := itemMap["targetId"] + if !hasTargetID { + return nil, false, errors.New("menu item targetId is required") + } + targetID, ok := targetIDRaw.(string) + if !ok || strings.TrimSpace(targetID) == "" { + return nil, false, errors.New("menu item targetId must be a string") + } + + toRaw, hasTo := itemMap["to"] + if !hasTo { + return nil, false, errors.New("menu item to is required") + } + to, ok := toRaw.(string) + if !ok || strings.TrimSpace(to) == "" || !strings.HasPrefix(strings.TrimSpace(to), "/") { + return nil, false, errors.New("menu item to must be an absolute path") + } + + normalizedItem := Doc{ + "id": normalizeSitemapItemID(itemMap["id"]), + "label": strings.TrimSpace(label), + "targetType": targetType, + "targetId": strings.TrimSpace(targetID), + "to": strings.TrimSpace(to), + } + + if childrenRaw, hasChildren := itemMap["children"]; hasChildren { + children, _, err := normalizeSitemapMenuItems(childrenRaw) + if err != nil { + return nil, false, err + } + normalizedItem["children"] = children + } else { + normalizedItem["children"] = []Doc{} + } + + normalized = append(normalized, normalizedItem) + } + + return normalized, true, nil +} + +func normalizeSitemapItemID(raw any) string { + id, ok := raw.(string) + if ok && strings.TrimSpace(id) != "" { + return strings.TrimSpace(id) + } + return uuid.NewString() +} + +func (a *app) validateSitemapMenuTargets(items []Doc) error { + for _, item := range items { + targetType, _ := item["targetType"].(string) + targetID, _ := item["targetId"].(string) + if err := a.validateSitemapTarget(targetType, targetID); err != nil { + return err + } + + if childAny, hasChildren := item["children"]; hasChildren { + children, _, err := normalizeSitemapMenuItems(childAny) + if err != nil { + return err + } + if err := a.validateSitemapMenuTargets(children); err != nil { + return err + } + } + } + return nil +} + +func (a *app) validateSitemapTarget(targetType, targetID string) error { + collection, ok := allowedSitemapTargetTypes[targetType] + if !ok { + return errors.New("menu item targetType is invalid") + } + + ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second) + defer cancel() + + count, err := a.db.Collection(collection).CountDocuments(ctx, Doc{"id": targetID}) + if err != nil { + return errors.New("failed to validate menu target") + } + if count == 0 { + return errors.New("menu target not found: " + targetType + ":" + targetID) + } + return nil +} diff --git a/Backend/cmd/api/storage_types.go b/Backend/cmd/api/storage_types.go new file mode 100644 index 0000000..a70b061 --- /dev/null +++ b/Backend/cmd/api/storage_types.go @@ -0,0 +1,41 @@ +package main + +import "errors" + +type Doc map[string]any +type List []any + +type SortField struct { + Key string + Value any +} + +type Sort []SortField + +var ErrNotFound = errors.New("resource not found") + +type FindOptions struct { + Sort Sort + Limit *int64 +} + +func NewFindOptions() *FindOptions { return &FindOptions{} } +func (o *FindOptions) SetSort(sort Sort) *FindOptions { o.Sort = sort; return o } +func (o *FindOptions) SetLimit(limit int64) *FindOptions { o.Limit = &limit; return o } + +type FindOneOptions struct{ Sort Sort } +func NewFindOneOptions() *FindOneOptions { return &FindOneOptions{} } +func (o *FindOneOptions) SetSort(sort Sort) *FindOneOptions { o.Sort = sort; return o } + +type ReturnDocument int +const ReturnAfter ReturnDocument = 1 + +type FindOneAndUpdateOptions struct{} +func NewFindOneAndUpdateOptions() *FindOneAndUpdateOptions { return &FindOneAndUpdateOptions{} } +func (o *FindOneAndUpdateOptions) SetReturnDocument(ReturnDocument) *FindOneAndUpdateOptions { return o } + +type CountOptions struct{} +type InsertOneOptions struct{} +type ReplaceOptions struct{} +type DeleteOptions struct{} +type UpdateOptions struct{} diff --git a/Backend/cmd/api/store.go b/Backend/cmd/api/store.go new file mode 100644 index 0000000..1827f82 --- /dev/null +++ b/Backend/cmd/api/store.go @@ -0,0 +1,248 @@ +package main + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/google/uuid" +) + +func (a *app) resolveCollection(name string) (*pgCollection, error) { + switch name { + case "pages", + "authoring_runs", + "datasources", + "scorm_courses", + "scorm_exports", + "users", + "enrollments", + "consent_receipts", + "data_subject_requests", + "webhooks", + "forms", + "form_submissions", + "sitemap_menus", + "audit_events", + "assets", + "asset_access_events", + "asset_claims", + "notification_templates", + "notification_preferences", + "notification_deliveries", + "notification_queue", + "notification_delivery_attempts", + "notification_dead_letters", + "notification_rate_limit_policies", + "notification_provider_configs", + "notifications", + "blog_posts", + "blog_categories", + "blog_tags", + "blog_authors", + "entitlements", + "live_courses", + "live_course_cohorts", + "live_course_sessions", + "live_course_enrollments", + "live_course_attendance", + "communities", + "community_channels", + "community_memberships", + "community_posts", + "community_replies", + "keycloak_themes", + "keycloak_theme_policy", + "keycloak_theme_exports", + "keycloak_theme_deployments_current", + "keycloak_theme_deployments_audit", + "platform_settings", + "admin_settings", + "billing_customers", + "billing_events", + "billing_price_policies", + "tenants": + return a.db.Collection(name), nil + default: + return nil, errors.New("invalid collection") + } +} + +func (a *app) list(collection string, filter Doc, sort Sort) ([]Doc, error) { + col, err := a.resolveCollection(collection) + if err != nil { + return nil, err + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + opts := NewFindOptions() + if len(sort) > 0 { + opts.SetSort(sort) + } + + cursor, err := col.Find(ctx, filter, opts) + if err != nil { + return nil, err + } + defer cursor.Close(ctx) + + var docs []Doc + if err := cursor.All(ctx, &docs); err != nil { + return nil, err + } + if docs == nil { + docs = []Doc{} + } + + for _, doc := range docs { + normalizeDocument(doc) + } + + return docs, nil +} + +func (a *app) getByID(collection, id string) (Doc, error) { + return a.getByIDScoped(collection, id, "", false) +} + +func (a *app) getByIDScoped(collection, id, tenantID string, enforceTenant bool) (Doc, error) { + col, err := a.resolveCollection(collection) + if err != nil { + return nil, err + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + filter := Doc{"id": id} + if enforceTenant { + filter["tenantId"] = tenantID + } + + var doc Doc + err = col.FindOne(ctx, filter).Decode(&doc) + if err != nil { + return nil, err + } + normalizeDocument(doc) + return doc, nil +} + +func (a *app) create(collection string, payload Doc) (Doc, error) { + col, err := a.resolveCollection(collection) + if err != nil { + return nil, err + } + + now := time.Now().UnixMilli() + if _, ok := payload["id"]; !ok { + payload["id"] = uuid.NewString() + } + if _, ok := payload["createdAt"]; !ok { + payload["createdAt"] = now + } + payload["updatedAt"] = now + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + _, err = col.InsertOne(ctx, payload) + if err != nil { + return nil, err + } + + normalizeDocument(payload) + return payload, nil +} + +func (a *app) patch(collection, id string, patch Doc) (Doc, error) { + return a.patchWithUnset(collection, id, patch, nil) +} + +func (a *app) patchWithUnset(collection, id string, patch Doc, unset Doc) (Doc, error) { + return a.patchWithUnsetScoped(collection, id, patch, unset, "", false) +} + +func (a *app) patchWithUnsetScoped(collection, id string, patch Doc, unset Doc, tenantID string, enforceTenant bool) (Doc, error) { + col, err := a.resolveCollection(collection) + if err != nil { + return nil, err + } + + delete(patch, "id") + delete(patch, "createdAt") + patch["updatedAt"] = time.Now().UnixMilli() + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + update := Doc{"$set": patch} + if len(unset) > 0 { + update["$unset"] = unset + } + + result := col.FindOneAndUpdate( + ctx, + func() Doc { + filter := Doc{"id": id} + if enforceTenant { + filter["tenantId"] = tenantID + } + return filter + }(), + update, + NewFindOneAndUpdateOptions().SetReturnDocument(ReturnAfter), + ) + + var updated Doc + if err := result.Decode(&updated); err != nil { + return nil, err + } + normalizeDocument(updated) + return updated, nil +} + +func (a *app) deleteByID(collection, id string) error { + return a.deleteByIDScoped(collection, id, "", false) +} + +func (a *app) deleteByIDScoped(collection, id, tenantID string, enforceTenant bool) error { + col, err := a.resolveCollection(collection) + if err != nil { + return err + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + filter := Doc{"id": id} + if enforceTenant { + filter["tenantId"] = tenantID + } + res, err := col.DeleteOne(ctx, filter) + if err != nil { + return err + } + if res.DeletedCount == 0 { + return ErrNotFound + } + return nil +} + +func (a *app) createScormExport(courseID, version string) (Doc, error) { + export := Doc{ + "id": uuid.NewString(), + "courseId": courseID, + "version": version, + "storageKey": fmt.Sprintf("exports/%s/%d.zip", courseID, time.Now().UnixNano()), + "createdAt": time.Now().UnixMilli(), + } + return a.create("scorm_exports", export) +} + +func normalizeDocument(doc Doc) { + delete(doc, "_id") +} diff --git a/Backend/cmd/api/tenancy.go b/Backend/cmd/api/tenancy.go new file mode 100644 index 0000000..b5b0094 --- /dev/null +++ b/Backend/cmd/api/tenancy.go @@ -0,0 +1,60 @@ +package main + +import ( + "errors" + "net/http" + "strings" +) + +var tenantScopedCollections = map[string]struct{}{ + "authoring_runs": {}, + "pages": {}, + "datasources": {}, + "scorm_courses": {}, + "users": {}, + "enrollments": {}, + "consent_receipts": {}, + "data_subject_requests": {}, + "webhooks": {}, + "entitlements": {}, + "billing_customers": {}, + "billing_events": {}, + "notification_templates": {}, + "notification_preferences": {}, + "notification_deliveries": {}, + "notification_queue": {}, + "notification_delivery_attempts": {}, + "notification_dead_letters": {}, + "notification_rate_limit_policies": {}, + "notification_provider_configs": {}, +} + +func isTenantScopedCollection(name string) bool { + _, ok := tenantScopedCollections[name] + return ok +} + +var errTenantClaimRequired = errors.New("tenant claim required for tenant-scoped resources") + +func requestTenantScope(r *http.Request, collection string) (tenantID string, enforce bool, err error) { + if !isTenantScopedCollection(collection) { + return "", false, nil + } + if r == nil { + return "", false, errTenantClaimRequired + } + tenant, ok := r.Context().Value(tenantContextKey).(tenantContext) + if !ok { + return "", false, errTenantClaimRequired + } + if tenant.BypassScoping { + if trimmed := strings.TrimSpace(tenant.ID); trimmed != "" { + return trimmed, tenant.RequestedByOps, nil + } + return "", false, nil + } + if trimmed := strings.TrimSpace(tenant.ID); trimmed != "" { + return trimmed, true, nil + } + return "", false, errTenantClaimRequired +} diff --git a/Backend/cmd/api/web.go b/Backend/cmd/api/web.go new file mode 100644 index 0000000..3a592a5 --- /dev/null +++ b/Backend/cmd/api/web.go @@ -0,0 +1,68 @@ +package main + +import ( + "net/http" + "os" + "path" + "path/filepath" + "strings" +) + +func (a *app) handler() http.Handler { + api := a.routes() + webDir := strings.TrimSpace(a.cfg.WebDir) + if webDir == "" { + return api + } + + indexPath := filepath.Join(webDir, "index.html") + + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/health" { + a.healthHandler(w, r) + return + } + + if isBackendPath(r.URL.Path) { + api.ServeHTTP(w, r) + return + } + + cleanPath := path.Clean("/" + r.URL.Path) + relativePath := strings.TrimPrefix(cleanPath, "/") + if relativePath != "" { + candidate := filepath.Join(webDir, filepath.FromSlash(relativePath)) + if info, err := os.Stat(candidate); err == nil && !info.IsDir() { + http.ServeFile(w, r, candidate) + return + } + + // Missing static assets should be real 404s rather than the SPA shell. + if filepath.Ext(relativePath) != "" { + http.NotFound(w, r) + return + } + } + + if _, err := os.Stat(indexPath); err != nil { + api.ServeHTTP(w, r) + return + } + http.ServeFile(w, r, indexPath) + }) +} + +func isBackendPath(requestPath string) bool { + switch { + case requestPath == "/api", strings.HasPrefix(requestPath, "/api/"): + return true + case requestPath == "/auth", strings.HasPrefix(requestPath, "/auth/"): + return true + case requestPath == "/.well-known", strings.HasPrefix(requestPath, "/.well-known/"): + return true + case requestPath == "/sitemap.xml", requestPath == "/robots.txt": + return true + default: + return false + } +} diff --git a/Backend/go.mod b/Backend/go.mod new file mode 100644 index 0000000..fb307f3 --- /dev/null +++ b/Backend/go.mod @@ -0,0 +1,21 @@ +module cms/backend + +go 1.22 + +require ( + github.com/coreos/go-oidc/v3 v3.11.0 + github.com/go-chi/chi/v5 v5.1.0 + github.com/google/uuid v1.6.0 + github.com/jackc/pgx/v5 v5.7.2 +) + +require ( + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/go-jose/go-jose/v4 v4.0.2 // indirect + golang.org/x/crypto v0.31.0 // indirect + golang.org/x/oauth2 v0.21.0 // indirect + golang.org/x/sync v0.10.0 // indirect + golang.org/x/text v0.21.0 // indirect +) diff --git a/Backend/go.sum b/Backend/go.sum new file mode 100644 index 0000000..eb4bca8 --- /dev/null +++ b/Backend/go.sum @@ -0,0 +1,65 @@ +github.com/coreos/go-oidc/v3 v3.11.0 h1:Ia3MxdwpSw702YW0xgfmP1GVCMA9aEFWu12XUZ3/OtI= +github.com/coreos/go-oidc/v3 v3.11.0/go.mod h1:gE3LgjOgFoHi9a4ce4/tJczr0Ai2/BoDhf0r5lltWI0= +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/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw= +github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= +github.com/go-jose/go-jose/v4 v4.0.2 h1:R3l3kkBds16bO7ZFAEEcofK0MkrAJt3jlJznWZG0nvk= +github.com/go-jose/go-jose/v4 v4.0.2/go.mod h1:WVf9LFMHh/QVrmqrOfqun0C45tMe3RoiKJMPvgWwLfY= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +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-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.7.2 h1:mLoDLV6sonKlvjIEsV56SkWNCnuNv531l94GaIzO+XI= +github.com/jackc/pgx/v5 v5.7.2/go.mod h1:ncY89UGWxg82EykZUwSpUKEfccBGGYq1xjrOpsbsfGQ= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +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/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8= +github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.26.0 h1:RrRspgV4mU+YwB4FYnuBoKsUapNIL5cohGAmSH3azsw= +golang.org/x/crypto v0.26.0/go.mod h1:GY7jblb9wI+FOo5y8/S2oY4zWP07AkOJ4+jxCqdqn54= +golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= +golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/oauth2 v0.21.0 h1:tsimM75w1tF/uws5rbeHzIWxEqElMehnc+iW793zsZs= +golang.org/x/oauth2 v0.21.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ= +golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ= +golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/text v0.17.0 h1:XtiM5bkSOt+ewxlOE/aE/AKEHibwj/6gvWMl9Rsh0Qc= +golang.org/x/text v0.17.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= +golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= +golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..c1bcfe2 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,50 @@ +# syntax=docker/dockerfile:1 + +FROM node:20-alpine AS web-builder +WORKDIR /src/Frontend + +COPY Frontend/package.json Frontend/package-lock.json ./ +RUN npm ci + +COPY Frontend/ ./ + +ARG VITE_API_BASE="" +ARG VITE_KEYCLOAK_URL="https://auth.arrowstack.dev" +ARG VITE_KEYCLOAK_REALM="dev" +ARG VITE_KEYCLOAK_CLIENT_ID="learn-platform" +ENV VITE_API_BASE=${VITE_API_BASE} \ + VITE_KEYCLOAK_URL=${VITE_KEYCLOAK_URL} \ + VITE_KEYCLOAK_REALM=${VITE_KEYCLOAK_REALM} \ + VITE_KEYCLOAK_CLIENT_ID=${VITE_KEYCLOAK_CLIENT_ID} + +RUN npm run build + +FROM golang:1.22-alpine AS api-builder +WORKDIR /src/Backend + +COPY Backend/go.mod Backend/go.sum ./ +RUN go mod download + +COPY Backend/ ./ +RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/learn-platform ./cmd/api + +FROM alpine:3.20 +RUN apk add --no-cache ca-certificates \ + && addgroup -S app \ + && adduser -S app -G app + +WORKDIR /app +COPY --from=api-builder /out/learn-platform /app/learn-platform +COPY --from=web-builder /src/Frontend/dist /app/web + +ENV PORT=8080 \ + WEB_DIR=/app/web \ + AUTH_DISABLED=false + +USER app +EXPOSE 8080 + +HEALTHCHECK --interval=15s --timeout=5s --start-period=20s --retries=5 \ + CMD wget -q -O /dev/null http://127.0.0.1:8080/health || exit 1 + +ENTRYPOINT ["/app/learn-platform"] diff --git a/Frontend/default_shadcn_theme.css b/Frontend/default_shadcn_theme.css new file mode 100644 index 0000000..39e1b44 --- /dev/null +++ b/Frontend/default_shadcn_theme.css @@ -0,0 +1,120 @@ +/* KEEP_IN_SYNC(fullscreen/resources/figmake/shadcn/globals.css) */ + +:root { + --font-size: 16px; + --background: #ffffff; + --foreground: oklch(0.145 0 0); + --card: #ffffff; + --card-foreground: oklch(0.145 0 0); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.145 0 0); + --primary: #030213; + --primary-foreground: oklch(1 0 0); + --secondary: oklch(0.95 0.0058 264.53); + --secondary-foreground: #030213; + --muted: #ececf0; + --muted-foreground: #717182; + --accent: #e9ebef; + --accent-foreground: #030213; + --destructive: #d4183d; + --destructive-foreground: #ffffff; + --border: rgba(0, 0, 0, 0.1); + --input: transparent; + --input-background: #f3f3f5; + --switch-background: #cbced4; + --font-weight-medium: 500; + --font-weight-normal: 400; + --ring: oklch(0.708 0 0); + --chart-1: oklch(0.646 0.222 41.116); + --chart-2: oklch(0.6 0.118 184.704); + --chart-3: oklch(0.398 0.07 227.392); + --chart-4: oklch(0.828 0.189 84.429); + --chart-5: oklch(0.769 0.188 70.08); + --radius: 0.625rem; + --sidebar: oklch(0.985 0 0); + --sidebar-foreground: oklch(0.145 0 0); + --sidebar-primary: #030213; + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.97 0 0); + --sidebar-accent-foreground: oklch(0.205 0 0); + --sidebar-border: oklch(0.922 0 0); + --sidebar-ring: oklch(0.708 0 0); +} + +.dark { + --background: oklch(0.145 0 0); + --foreground: oklch(0.985 0 0); + --card: oklch(0.145 0 0); + --card-foreground: oklch(0.985 0 0); + --popover: oklch(0.145 0 0); + --popover-foreground: oklch(0.985 0 0); + --primary: oklch(0.985 0 0); + --primary-foreground: oklch(0.205 0 0); + --secondary: oklch(0.269 0 0); + --secondary-foreground: oklch(0.985 0 0); + --muted: oklch(0.269 0 0); + --muted-foreground: oklch(0.708 0 0); + --accent: oklch(0.269 0 0); + --accent-foreground: oklch(0.985 0 0); + --destructive: oklch(0.396 0.141 25.723); + --destructive-foreground: oklch(0.637 0.237 25.331); + --border: oklch(0.269 0 0); + --input: oklch(0.269 0 0); + --ring: oklch(0.439 0 0); + --font-weight-medium: 500; + --font-weight-normal: 400; + --chart-1: oklch(0.488 0.243 264.376); + --chart-2: oklch(0.696 0.17 162.48); + --chart-3: oklch(0.769 0.188 70.08); + --chart-4: oklch(0.627 0.265 303.9); + --chart-5: oklch(0.645 0.246 16.439); + --sidebar: oklch(0.205 0 0); + --sidebar-foreground: oklch(0.985 0 0); + --sidebar-primary: oklch(0.488 0.243 264.376); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.269 0 0); + --sidebar-accent-foreground: oklch(0.985 0 0); + --sidebar-border: oklch(0.269 0 0); + --sidebar-ring: oklch(0.439 0 0); +} + +@theme inline { + --color-background: var(--background); + --color-foreground: var(--foreground); + --color-card: var(--card); + --color-card-foreground: var(--card-foreground); + --color-popover: var(--popover); + --color-popover-foreground: var(--popover-foreground); + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); + --color-secondary: var(--secondary); + --color-secondary-foreground: var(--secondary-foreground); + --color-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); + --color-accent: var(--accent); + --color-accent-foreground: var(--accent-foreground); + --color-destructive: var(--destructive); + --color-destructive-foreground: var(--destructive-foreground); + --color-border: var(--border); + --color-input: var(--input); + --color-input-background: var(--input-background); + --color-switch-background: var(--switch-background); + --color-ring: var(--ring); + --color-chart-1: var(--chart-1); + --color-chart-2: var(--chart-2); + --color-chart-3: var(--chart-3); + --color-chart-4: var(--chart-4); + --color-chart-5: var(--chart-5); + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); + --color-sidebar: var(--sidebar); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-ring: var(--sidebar-ring); +} diff --git a/Frontend/index.html b/Frontend/index.html new file mode 100644 index 0000000..4e7ef7e --- /dev/null +++ b/Frontend/index.html @@ -0,0 +1,22 @@ + + + + + + + + Expand Theme Building + + + + + + + + +
+ + + + + \ No newline at end of file diff --git a/Frontend/package-lock.json b/Frontend/package-lock.json new file mode 100644 index 0000000..bc03d38 --- /dev/null +++ b/Frontend/package-lock.json @@ -0,0 +1,6132 @@ +{ + "name": "@figma/my-make-file", + "version": "0.0.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@figma/my-make-file", + "version": "0.0.1", + "dependencies": { + "@emotion/react": "11.14.0", + "@emotion/styled": "11.14.1", + "@mui/icons-material": "7.3.5", + "@mui/material": "7.3.5", + "@popperjs/core": "2.11.8", + "@radix-ui/react-accordion": "1.2.3", + "@radix-ui/react-alert-dialog": "1.1.6", + "@radix-ui/react-aspect-ratio": "1.1.2", + "@radix-ui/react-avatar": "1.1.3", + "@radix-ui/react-checkbox": "1.1.4", + "@radix-ui/react-collapsible": "1.1.3", + "@radix-ui/react-context-menu": "2.2.6", + "@radix-ui/react-dialog": "1.1.6", + "@radix-ui/react-dropdown-menu": "2.1.6", + "@radix-ui/react-hover-card": "1.1.6", + "@radix-ui/react-label": "2.1.2", + "@radix-ui/react-menubar": "1.1.6", + "@radix-ui/react-navigation-menu": "1.2.5", + "@radix-ui/react-popover": "1.1.6", + "@radix-ui/react-progress": "1.1.2", + "@radix-ui/react-radio-group": "1.2.3", + "@radix-ui/react-scroll-area": "1.2.3", + "@radix-ui/react-select": "2.1.6", + "@radix-ui/react-separator": "1.1.2", + "@radix-ui/react-slider": "1.2.3", + "@radix-ui/react-slot": "1.1.2", + "@radix-ui/react-switch": "1.1.3", + "@radix-ui/react-tabs": "1.1.3", + "@radix-ui/react-toggle": "1.1.2", + "@radix-ui/react-toggle-group": "1.1.2", + "@radix-ui/react-tooltip": "1.1.8", + "@types/jszip": "^3.4.1", + "canvas-confetti": "1.9.4", + "class-variance-authority": "0.7.1", + "clsx": "2.1.1", + "cmdk": "1.1.1", + "date-fns": "3.6.0", + "embla-carousel-react": "8.6.0", + "input-otp": "1.4.2", + "jszip": "^3.10.1", + "keycloak-js": "26.1.1", + "lucide-react": "0.487.0", + "motion": "12.23.24", + "next-themes": "0.4.6", + "react-day-picker": "8.10.1", + "react-dnd": "16.0.1", + "react-dnd-html5-backend": "16.0.1", + "react-hook-form": "7.55.0", + "react-popper": "2.3.0", + "react-resizable-panels": "2.1.7", + "react-responsive-masonry": "2.7.1", + "react-router": "7.17.0", + "react-slick": "0.31.0", + "recharts": "2.15.2", + "sonner": "2.0.3", + "tailwind-merge": "3.2.0", + "tw-animate-css": "1.3.8", + "vaul": "1.1.2" + }, + "devDependencies": { + "@playwright/test": "^1.60.0", + "@tailwindcss/vite": "4.1.12", + "@vitejs/plugin-react": "4.7.0", + "tailwindcss": "4.1.12", + "vite": "6.4.3", + "vitest": "4.1.8" + }, + "peerDependencies": { + "react": "18.3.1", + "react-dom": "18.3.1" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/core/node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@babel/generator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@emotion/babel-plugin": { + "version": "11.13.5", + "resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz", + "integrity": "sha512-pxHCpT2ex+0q+HH91/zsdHkw/lXd468DIN2zvfvLtPKLLMo6gQj7oLObq8PhkrxOZb/gGCq03S3Z7PDhS8pduQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.16.7", + "@babel/runtime": "^7.18.3", + "@emotion/hash": "^0.9.2", + "@emotion/memoize": "^0.9.0", + "@emotion/serialize": "^1.3.3", + "babel-plugin-macros": "^3.1.0", + "convert-source-map": "^1.5.0", + "escape-string-regexp": "^4.0.0", + "find-root": "^1.1.0", + "source-map": "^0.5.7", + "stylis": "4.2.0" + } + }, + "node_modules/@emotion/cache": { + "version": "11.14.0", + "resolved": "https://registry.npmjs.org/@emotion/cache/-/cache-11.14.0.tgz", + "integrity": "sha512-L/B1lc/TViYk4DcpGxtAVbx0ZyiKM5ktoIyafGkH6zg/tj+mA+NE//aPYKG0k8kCHSHVJrpLpcAlOBEXQ3SavA==", + "license": "MIT", + "dependencies": { + "@emotion/memoize": "^0.9.0", + "@emotion/sheet": "^1.4.0", + "@emotion/utils": "^1.4.2", + "@emotion/weak-memoize": "^0.4.0", + "stylis": "4.2.0" + } + }, + "node_modules/@emotion/hash": { + "version": "0.9.2", + "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.2.tgz", + "integrity": "sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==", + "license": "MIT" + }, + "node_modules/@emotion/is-prop-valid": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-1.4.0.tgz", + "integrity": "sha512-QgD4fyscGcbbKwJmqNvUMSE02OsHUa+lAWKdEUIJKgqe5IwRSKd7+KhibEWdaKwgjLj0DRSHA9biAIqGBk05lw==", + "license": "MIT", + "dependencies": { + "@emotion/memoize": "^0.9.0" + } + }, + "node_modules/@emotion/memoize": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.9.0.tgz", + "integrity": "sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ==", + "license": "MIT" + }, + "node_modules/@emotion/react": { + "version": "11.14.0", + "resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz", + "integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "@emotion/babel-plugin": "^11.13.5", + "@emotion/cache": "^11.14.0", + "@emotion/serialize": "^1.3.3", + "@emotion/use-insertion-effect-with-fallbacks": "^1.2.0", + "@emotion/utils": "^1.4.2", + "@emotion/weak-memoize": "^0.4.0", + "hoist-non-react-statics": "^3.3.1" + }, + "peerDependencies": { + "react": ">=16.8.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@emotion/serialize": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@emotion/serialize/-/serialize-1.3.3.tgz", + "integrity": "sha512-EISGqt7sSNWHGI76hC7x1CksiXPahbxEOrC5RjmFRJTqLyEK9/9hZvBbiYn70dw4wuwMKiEMCUlR6ZXTSWQqxA==", + "license": "MIT", + "dependencies": { + "@emotion/hash": "^0.9.2", + "@emotion/memoize": "^0.9.0", + "@emotion/unitless": "^0.10.0", + "@emotion/utils": "^1.4.2", + "csstype": "^3.0.2" + } + }, + "node_modules/@emotion/sheet": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@emotion/sheet/-/sheet-1.4.0.tgz", + "integrity": "sha512-fTBW9/8r2w3dXWYM4HCB1Rdp8NLibOw2+XELH5m5+AkWiL/KqYX6dc0kKYlaYyKjrQ6ds33MCdMPEwgs2z1rqg==", + "license": "MIT" + }, + "node_modules/@emotion/styled": { + "version": "11.14.1", + "resolved": "https://registry.npmjs.org/@emotion/styled/-/styled-11.14.1.tgz", + "integrity": "sha512-qEEJt42DuToa3gurlH4Qqc1kVpNq8wO8cJtDzU46TjlzWjDlsVyevtYCRijVq3SrHsROS+gVQ8Fnea108GnKzw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "@emotion/babel-plugin": "^11.13.5", + "@emotion/is-prop-valid": "^1.3.0", + "@emotion/serialize": "^1.3.3", + "@emotion/use-insertion-effect-with-fallbacks": "^1.2.0", + "@emotion/utils": "^1.4.2" + }, + "peerDependencies": { + "@emotion/react": "^11.0.0-rc.0", + "react": ">=16.8.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@emotion/unitless": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.10.0.tgz", + "integrity": "sha512-dFoMUuQA20zvtVTuxZww6OHoJYgrzfKM1t52mVySDJnMSEa08ruEvdYQbhvyu6soU+NeLVd3yKfTfT0NeV6qGg==", + "license": "MIT" + }, + "node_modules/@emotion/use-insertion-effect-with-fallbacks": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@emotion/use-insertion-effect-with-fallbacks/-/use-insertion-effect-with-fallbacks-1.2.0.tgz", + "integrity": "sha512-yJMtVdH59sxi/aVJBpk9FQq+OR8ll5GT8oWd57UpeaKEVGab41JWaCFA7FRLoMLloOZF/c/wsPoe+bfGmRKgDg==", + "license": "MIT", + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@emotion/utils": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/@emotion/utils/-/utils-1.4.2.tgz", + "integrity": "sha512-3vLclRofFziIa3J2wDh9jjbkUz9qk5Vi3IZ/FSTKViB0k+ef0fPV7dYrUIugbgupYDx7v9ud/SjrtEP8Y4xLoA==", + "license": "MIT" + }, + "node_modules/@emotion/weak-memoize": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@emotion/weak-memoize/-/weak-memoize-0.4.0.tgz", + "integrity": "sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg==", + "license": "MIT" + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@floating-ui/core": { + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz", + "integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz", + "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.5", + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/react-dom": { + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.8.tgz", + "integrity": "sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A==", + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.7.6" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz", + "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", + "license": "MIT" + }, + "node_modules/@isaacs/fs-minipass": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", + "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.4" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@mui/core-downloads-tracker": { + "version": "7.3.11", + "resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-7.3.11.tgz", + "integrity": "sha512-a7I/b/nBTdXYz2cOSlEmkQ9WWE1x8FHpqMhFPp+Y1VPFxcOw91G5ELOHARQAGSPy5V+UCgJua6K/1x70bAtQPw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + } + }, + "node_modules/@mui/icons-material": { + "version": "7.3.5", + "resolved": "https://registry.npmjs.org/@mui/icons-material/-/icons-material-7.3.5.tgz", + "integrity": "sha512-LciL1GLMZ+VlzyHAALSVAR22t8IST4LCXmljcUSx2NOutgO2XnxdIp8ilFbeNf9wpo0iUFbAuoQcB7h+HHIf3A==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@mui/material": "^7.3.5", + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/material": { + "version": "7.3.5", + "resolved": "https://registry.npmjs.org/@mui/material/-/material-7.3.5.tgz", + "integrity": "sha512-8VVxFmp1GIm9PpmnQoCoYo0UWHoOrdA57tDL62vkpzEgvb/d71Wsbv4FRg7r1Gyx7PuSo0tflH34cdl/NvfHNQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "@mui/core-downloads-tracker": "^7.3.5", + "@mui/system": "^7.3.5", + "@mui/types": "^7.4.8", + "@mui/utils": "^7.3.5", + "@popperjs/core": "^2.11.8", + "@types/react-transition-group": "^4.4.12", + "clsx": "^2.1.1", + "csstype": "^3.1.3", + "prop-types": "^15.8.1", + "react-is": "^19.2.0", + "react-transition-group": "^4.4.5" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@emotion/react": "^11.5.0", + "@emotion/styled": "^11.3.0", + "@mui/material-pigment-css": "^7.3.5", + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + }, + "@mui/material-pigment-css": { + "optional": true + }, + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/private-theming": { + "version": "7.3.11", + "resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-7.3.11.tgz", + "integrity": "sha512-9B+YKms0fRHbNrqp9tOT/DNbNnU5gyvJ1o3qAGXfq8GmZcbJnE3At9x07Zr/o0pkhzg4aDdwXVqe4+AcgtOCPA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.6", + "@mui/utils": "^7.3.11", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/styled-engine": { + "version": "7.3.10", + "resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-7.3.10.tgz", + "integrity": "sha512-WxE9SiF8xskAQqGjsp0poXCkCqsoXFEsSr0HBXfApmGHR+DBnXRp+z46Vsltg4gpPM4Z96DeAQRpeAOnhNg7Ng==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.6", + "@emotion/cache": "^11.14.0", + "@emotion/serialize": "^1.3.3", + "@emotion/sheet": "^1.4.0", + "csstype": "^3.2.3", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@emotion/react": "^11.4.1", + "@emotion/styled": "^11.3.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + } + } + }, + "node_modules/@mui/system": { + "version": "7.3.11", + "resolved": "https://registry.npmjs.org/@mui/system/-/system-7.3.11.tgz", + "integrity": "sha512-7izwGWdNawAKpBKcRlx7f2gFnAAjmASBWvMcyX4YYEeLOFsbfGRbUYGInvnAcUeql3rPxI7F9Ft4oY2OLRz44g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.6", + "@mui/private-theming": "^7.3.11", + "@mui/styled-engine": "^7.3.10", + "@mui/types": "^7.4.12", + "@mui/utils": "^7.3.11", + "clsx": "^2.1.1", + "csstype": "^3.2.3", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@emotion/react": "^11.5.0", + "@emotion/styled": "^11.3.0", + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + }, + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/types": { + "version": "7.4.12", + "resolved": "https://registry.npmjs.org/@mui/types/-/types-7.4.12.tgz", + "integrity": "sha512-iKNAF2u9PzSIj40CjvKJWxFXJo122jXVdrmdh0hMYd+FR+NuJMkr/L88XwWLCRiJ5P1j+uyac25+Kp6YC4hu6w==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.6" + }, + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/utils": { + "version": "7.3.11", + "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-7.3.11.tgz", + "integrity": "sha512-XTjGnifwteg71/ij+0e7Y7d+hwyntMYP5wPoA/g2drdGH+Flkvjwy0OfrVpKBbaOvofq4zU/LIyUZyKgmWu18g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.6", + "@mui/types": "^7.4.12", + "@types/prop-types": "^15.7.15", + "clsx": "^2.1.1", + "prop-types": "^15.8.1", + "react-is": "^19.2.3" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@playwright/test": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.60.0.tgz", + "integrity": "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.60.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@popperjs/core": { + "version": "2.11.8", + "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", + "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/popperjs" + } + }, + "node_modules/@radix-ui/number": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.0.tgz", + "integrity": "sha512-V3gRzhVNU1ldS5XhAPTom1fOIo4ccrjjJgmE+LI2h/WaFpHmx0MQApT+KZHnx8abG6Avtfcz4WoEciMnpFT3HQ==", + "license": "MIT" + }, + "node_modules/@radix-ui/primitive": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.1.tgz", + "integrity": "sha512-SJ31y+Q/zAyShtXJc8x83i9TYdbAfHZ++tUZnvjJJqFjzsdUnKsxPL6IEtBlxKkU7yzer//GQtZSV4GbldL3YA==", + "license": "MIT" + }, + "node_modules/@radix-ui/react-accordion": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-accordion/-/react-accordion-1.2.3.tgz", + "integrity": "sha512-RIQ15mrcvqIkDARJeERSuXSry2N8uYnxkdDetpfmalT/+0ntOXLkFOsh9iwlAsCv+qcmhZjbdJogIm6WBa6c4A==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-collapsible": "1.1.3", + "@radix-ui/react-collection": "1.1.2", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-direction": "1.1.0", + "@radix-ui/react-id": "1.1.0", + "@radix-ui/react-primitive": "2.0.2", + "@radix-ui/react-use-controllable-state": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-alert-dialog": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-alert-dialog/-/react-alert-dialog-1.1.6.tgz", + "integrity": "sha512-p4XnPqgej8sZAAReCAKgz1REYZEBLR8hU9Pg27wFnCWIMc8g1ccCs0FjBcy05V15VTu8pAePw/VDYeOm/uZ6yQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-dialog": "1.1.6", + "@radix-ui/react-primitive": "2.0.2", + "@radix-ui/react-slot": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-arrow": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.2.tgz", + "integrity": "sha512-G+KcpzXHq24iH0uGG/pF8LyzpFJYGD4RfLjCIBfGdSLXvjLHST31RUiRVrupIBMvIppMgSzQ6l66iAxl03tdlg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.0.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-aspect-ratio": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-aspect-ratio/-/react-aspect-ratio-1.1.2.tgz", + "integrity": "sha512-TaJxYoCpxJ7vfEkv2PTNox/6zzmpKXT6ewvCuf2tTOIVN45/Jahhlld29Yw4pciOXS2Xq91/rSGEdmEnUWZCqA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.0.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-avatar": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-avatar/-/react-avatar-1.1.3.tgz", + "integrity": "sha512-Paen00T4P8L8gd9bNsRMw7Cbaz85oxiv+hzomsRZgFm2byltPFDtfcoqlWJ8GyZlIBWgLssJlzLCnKU0G0302g==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-primitive": "2.0.2", + "@radix-ui/react-use-callback-ref": "1.1.0", + "@radix-ui/react-use-layout-effect": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-checkbox": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-checkbox/-/react-checkbox-1.1.4.tgz", + "integrity": "sha512-wP0CPAHq+P5I4INKe3hJrIa1WoNqqrejzW+zoU0rOvo1b9gDEJJFl2rYfO1PYJUQCc2H1WZxIJmyv9BS8i5fLw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-presence": "1.1.2", + "@radix-ui/react-primitive": "2.0.2", + "@radix-ui/react-use-controllable-state": "1.1.0", + "@radix-ui/react-use-previous": "1.1.0", + "@radix-ui/react-use-size": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collapsible": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.1.3.tgz", + "integrity": "sha512-jFSerheto1X03MUC0g6R7LedNW9EEGWdg9W1+MlpkMLwGkgkbUXLPBH/KIuWKXUoeYRVY11llqbTBDzuLg7qrw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-id": "1.1.0", + "@radix-ui/react-presence": "1.1.2", + "@radix-ui/react-primitive": "2.0.2", + "@radix-ui/react-use-controllable-state": "1.1.0", + "@radix-ui/react-use-layout-effect": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collection": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.2.tgz", + "integrity": "sha512-9z54IEKRxIa9VityapoEYMuByaG42iSy1ZXlY2KcuLSEtq8x4987/N6m15ppoMffgZX72gER2uHe1D9Y6Unlcw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-primitive": "2.0.2", + "@radix-ui/react-slot": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.1.tgz", + "integrity": "sha512-Y9VzoRDSJtgFMUCoiZBDVo084VQ5hfpXxVE+NgkdNsjiDBByiImMZKKhxMwCbdHvhlENG6a833CbFkOQvTricw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-context": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.1.tgz", + "integrity": "sha512-UASk9zi+crv9WteK/NU4PLvOoL3OuE6BWVKNF6hPRBtYBDXQ2u5iu3O59zUlJiTVvkyuycnqrztsHVJwcK9K+Q==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-context-menu": { + "version": "2.2.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context-menu/-/react-context-menu-2.2.6.tgz", + "integrity": "sha512-aUP99QZ3VU84NPsHeaFt4cQUNgJqFsLLOt/RbbWXszZ6MP0DpDyjkFZORr4RpAEx3sUBk+Kc8h13yGtC5Qw8dg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-menu": "2.1.6", + "@radix-ui/react-primitive": "2.0.2", + "@radix-ui/react-use-callback-ref": "1.1.0", + "@radix-ui/react-use-controllable-state": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.6.tgz", + "integrity": "sha512-/IVhJV5AceX620DUJ4uYVMymzsipdKBzo3edo+omeskCKGm9FRHM0ebIdbPnlQVJqyuHbuBltQUOG2mOTq2IYw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.5", + "@radix-ui/react-focus-guards": "1.1.1", + "@radix-ui/react-focus-scope": "1.1.2", + "@radix-ui/react-id": "1.1.0", + "@radix-ui/react-portal": "1.1.4", + "@radix-ui/react-presence": "1.1.2", + "@radix-ui/react-primitive": "2.0.2", + "@radix-ui/react-slot": "1.1.2", + "@radix-ui/react-use-controllable-state": "1.1.0", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-direction": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.0.tgz", + "integrity": "sha512-BUuBvgThEiAXh2DWu93XsT+a3aWrGqolGlqqw5VU1kG7p/ZH2cuDlM1sRLNnY3QcBS69UIz2mcKhMxDsdewhjg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.5.tgz", + "integrity": "sha512-E4TywXY6UsXNRhFrECa5HAvE5/4BFcGyfTyK36gP+pAW1ed7UTK4vKwdr53gAJYwqbfCWC6ATvJa3J3R/9+Qrg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-primitive": "2.0.2", + "@radix-ui/react-use-callback-ref": "1.1.0", + "@radix-ui/react-use-escape-keydown": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dropdown-menu": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.1.6.tgz", + "integrity": "sha512-no3X7V5fD487wab/ZYSHXq3H37u4NVeLDKI/Ks724X/eEFSSEFYZxWgsIlr1UBeEyDaM29HM5x9p1Nv8DuTYPA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-id": "1.1.0", + "@radix-ui/react-menu": "2.1.6", + "@radix-ui/react-primitive": "2.0.2", + "@radix-ui/react-use-controllable-state": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-guards": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.1.tgz", + "integrity": "sha512-pSIwfrT1a6sIoDASCSpFwOasEwKTZWDw/iBdtnqKO7v6FeOzYJ7U53cPzYFVR3geGGXgVHaH+CdngrrAzqUGxg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-scope": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.2.tgz", + "integrity": "sha512-zxwE80FCU7lcXUGWkdt6XpTTCKPitG1XKOwViTxHVKIJhZl9MvIl2dVHeZENCWD9+EdWv05wlaEkRXUykU27RA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-primitive": "2.0.2", + "@radix-ui/react-use-callback-ref": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-hover-card": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-hover-card/-/react-hover-card-1.1.6.tgz", + "integrity": "sha512-E4ozl35jq0VRlrdc4dhHrNSV0JqBb4Jy73WAhBEK7JoYnQ83ED5r0Rb/XdVKw89ReAJN38N492BAPBZQ57VmqQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.5", + "@radix-ui/react-popper": "1.2.2", + "@radix-ui/react-portal": "1.1.4", + "@radix-ui/react-presence": "1.1.2", + "@radix-ui/react-primitive": "2.0.2", + "@radix-ui/react-use-controllable-state": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-id": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.0.tgz", + "integrity": "sha512-EJUrI8yYh7WOjNOqpoJaf1jlFIH2LvtgAl+YcFqNCa+4hj64ZXmPkAKOFs/ukjz3byN6bdb/AVUqHkI8/uWWMA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-label": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.2.tgz", + "integrity": "sha512-zo1uGMTaNlHehDyFQcDZXRJhUPDuukcnHz0/jnrup0JA6qL+AFpAnty+7VKa9esuU5xTblAZzTGYJKSKaBxBhw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.0.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-menu": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.6.tgz", + "integrity": "sha512-tBBb5CXDJW3t2mo9WlO7r6GTmWV0F0uzHZVFmlRmYpiSK1CDU5IKojP1pm7oknpBOrFZx/YgBRW9oorPO2S/Lg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-collection": "1.1.2", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-direction": "1.1.0", + "@radix-ui/react-dismissable-layer": "1.1.5", + "@radix-ui/react-focus-guards": "1.1.1", + "@radix-ui/react-focus-scope": "1.1.2", + "@radix-ui/react-id": "1.1.0", + "@radix-ui/react-popper": "1.2.2", + "@radix-ui/react-portal": "1.1.4", + "@radix-ui/react-presence": "1.1.2", + "@radix-ui/react-primitive": "2.0.2", + "@radix-ui/react-roving-focus": "1.1.2", + "@radix-ui/react-slot": "1.1.2", + "@radix-ui/react-use-callback-ref": "1.1.0", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-menubar": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-menubar/-/react-menubar-1.1.6.tgz", + "integrity": "sha512-FHq7+3DlXwh/7FOM4i0G4bC4vPjiq89VEEvNF4VMLchGnaUuUbE5uKXMUCjdKaOghEEMeiKa5XCa2Pk4kteWmg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-collection": "1.1.2", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-direction": "1.1.0", + "@radix-ui/react-id": "1.1.0", + "@radix-ui/react-menu": "2.1.6", + "@radix-ui/react-primitive": "2.0.2", + "@radix-ui/react-roving-focus": "1.1.2", + "@radix-ui/react-use-controllable-state": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-navigation-menu": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-navigation-menu/-/react-navigation-menu-1.2.5.tgz", + "integrity": "sha512-myMHHQUZ3ZLTi8W381/Vu43Ia0NqakkQZ2vzynMmTUtQQ9kNkjzhOwkZC9TAM5R07OZUVIQyHC06f/9JZJpvvA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-collection": "1.1.2", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-direction": "1.1.0", + "@radix-ui/react-dismissable-layer": "1.1.5", + "@radix-ui/react-id": "1.1.0", + "@radix-ui/react-presence": "1.1.2", + "@radix-ui/react-primitive": "2.0.2", + "@radix-ui/react-use-callback-ref": "1.1.0", + "@radix-ui/react-use-controllable-state": "1.1.0", + "@radix-ui/react-use-layout-effect": "1.1.0", + "@radix-ui/react-use-previous": "1.1.0", + "@radix-ui/react-visually-hidden": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popover": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.6.tgz", + "integrity": "sha512-NQouW0x4/GnkFJ/pRqsIS3rM/k97VzKnVb2jB7Gq7VEGPy5g7uNV1ykySFt7eWSp3i2uSGFwaJcvIRJBAHmmFg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.5", + "@radix-ui/react-focus-guards": "1.1.1", + "@radix-ui/react-focus-scope": "1.1.2", + "@radix-ui/react-id": "1.1.0", + "@radix-ui/react-popper": "1.2.2", + "@radix-ui/react-portal": "1.1.4", + "@radix-ui/react-presence": "1.1.2", + "@radix-ui/react-primitive": "2.0.2", + "@radix-ui/react-slot": "1.1.2", + "@radix-ui/react-use-controllable-state": "1.1.0", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popper": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.2.tgz", + "integrity": "sha512-Rvqc3nOpwseCyj/rgjlJDYAgyfw7OC1tTkKn2ivhaMGcYt8FSBlahHOZak2i3QwkRXUXgGgzeEe2RuqeEHuHgA==", + "license": "MIT", + "dependencies": { + "@floating-ui/react-dom": "^2.0.0", + "@radix-ui/react-arrow": "1.1.2", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-primitive": "2.0.2", + "@radix-ui/react-use-callback-ref": "1.1.0", + "@radix-ui/react-use-layout-effect": "1.1.0", + "@radix-ui/react-use-rect": "1.1.0", + "@radix-ui/react-use-size": "1.1.0", + "@radix-ui/rect": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-portal": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.4.tgz", + "integrity": "sha512-sn2O9k1rPFYVyKd5LAJfo96JlSGVFpa1fS6UuBJfrZadudiw5tAmru+n1x7aMRQ84qDM71Zh1+SzK5QwU0tJfA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.0.2", + "@radix-ui/react-use-layout-effect": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-presence": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.2.tgz", + "integrity": "sha512-18TFr80t5EVgL9x1SwF/YGtfG+l0BS0PRAlCWBDoBEiDQjeKgnNZRVJp/oVBl24sr3Gbfwc/Qpj4OcWTQMsAEg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-use-layout-effect": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-primitive": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.0.2.tgz", + "integrity": "sha512-Ec/0d38EIuvDF+GZjcMU/Ze6MxntVJYO/fRlCPhCaVUyPY9WTalHJw54tp9sXeJo3tlShWpy41vQRgLRGOuz+w==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-progress": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-progress/-/react-progress-1.1.2.tgz", + "integrity": "sha512-u1IgJFQ4zNAUTjGdDL5dcl/U8ntOR6jsnhxKb5RKp5Ozwl88xKR9EqRZOe/Mk8tnx0x5tNUe2F+MzsyjqMg0MA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-primitive": "2.0.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-radio-group": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-radio-group/-/react-radio-group-1.2.3.tgz", + "integrity": "sha512-xtCsqt8Rp09FK50ItqEqTJ7Sxanz8EM8dnkVIhJrc/wkMMomSmXHvYbhv3E7Zx4oXh98aaLt9W679SUYXg4IDA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-direction": "1.1.0", + "@radix-ui/react-presence": "1.1.2", + "@radix-ui/react-primitive": "2.0.2", + "@radix-ui/react-roving-focus": "1.1.2", + "@radix-ui/react-use-controllable-state": "1.1.0", + "@radix-ui/react-use-previous": "1.1.0", + "@radix-ui/react-use-size": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-roving-focus": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.2.tgz", + "integrity": "sha512-zgMQWkNO169GtGqRvYrzb0Zf8NhMHS2DuEB/TiEmVnpr5OqPU3i8lfbxaAmC2J/KYuIQxyoQQ6DxepyXp61/xw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-collection": "1.1.2", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-direction": "1.1.0", + "@radix-ui/react-id": "1.1.0", + "@radix-ui/react-primitive": "2.0.2", + "@radix-ui/react-use-callback-ref": "1.1.0", + "@radix-ui/react-use-controllable-state": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-scroll-area": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-scroll-area/-/react-scroll-area-1.2.3.tgz", + "integrity": "sha512-l7+NNBfBYYJa9tNqVcP2AGvxdE3lmE6kFTBXdvHgUaZuy+4wGCL1Cl2AfaR7RKyimj7lZURGLwFO59k4eBnDJQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.0", + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-direction": "1.1.0", + "@radix-ui/react-presence": "1.1.2", + "@radix-ui/react-primitive": "2.0.2", + "@radix-ui/react-use-callback-ref": "1.1.0", + "@radix-ui/react-use-layout-effect": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-select": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.1.6.tgz", + "integrity": "sha512-T6ajELxRvTuAMWH0YmRJ1qez+x4/7Nq7QIx7zJ0VK3qaEWdnWpNbEDnmWldG1zBDwqrLy5aLMUWcoGirVj5kMg==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.0", + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-collection": "1.1.2", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-direction": "1.1.0", + "@radix-ui/react-dismissable-layer": "1.1.5", + "@radix-ui/react-focus-guards": "1.1.1", + "@radix-ui/react-focus-scope": "1.1.2", + "@radix-ui/react-id": "1.1.0", + "@radix-ui/react-popper": "1.2.2", + "@radix-ui/react-portal": "1.1.4", + "@radix-ui/react-primitive": "2.0.2", + "@radix-ui/react-slot": "1.1.2", + "@radix-ui/react-use-callback-ref": "1.1.0", + "@radix-ui/react-use-controllable-state": "1.1.0", + "@radix-ui/react-use-layout-effect": "1.1.0", + "@radix-ui/react-use-previous": "1.1.0", + "@radix-ui/react-visually-hidden": "1.1.2", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-separator": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.2.tgz", + "integrity": "sha512-oZfHcaAp2Y6KFBX6I5P1u7CQoy4lheCGiYj+pGFrHy8E/VNRb5E39TkTr3JrV520csPBTZjkuKFdEsjS5EUNKQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.0.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slider": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slider/-/react-slider-1.2.3.tgz", + "integrity": "sha512-nNrLAWLjGESnhqBqcCNW4w2nn7LxudyMzeB6VgdyAnFLC6kfQgnAjSL2v6UkQTnDctJBlxrmxfplWS4iYjdUTw==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.0", + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-collection": "1.1.2", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-direction": "1.1.0", + "@radix-ui/react-primitive": "2.0.2", + "@radix-ui/react-use-controllable-state": "1.1.0", + "@radix-ui/react-use-layout-effect": "1.1.0", + "@radix-ui/react-use-previous": "1.1.0", + "@radix-ui/react-use-size": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slot": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.2.tgz", + "integrity": "sha512-YAKxaiGsSQJ38VzKH86/BPRC4rh+b1Jpa+JneA5LRE7skmLPNAyeG8kPJj/oo4STLvlrs8vkf/iYyc3A5stYCQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-switch": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-switch/-/react-switch-1.1.3.tgz", + "integrity": "sha512-1nc+vjEOQkJVsJtWPSiISGT6OKm4SiOdjMo+/icLxo2G4vxz1GntC5MzfL4v8ey9OEfw787QCD1y3mUv0NiFEQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-primitive": "2.0.2", + "@radix-ui/react-use-controllable-state": "1.1.0", + "@radix-ui/react-use-previous": "1.1.0", + "@radix-ui/react-use-size": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tabs": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.3.tgz", + "integrity": "sha512-9mFyI30cuRDImbmFF6O2KUJdgEOsGh9Vmx9x/Dh9tOhL7BngmQPQfwW4aejKm5OHpfWIdmeV6ySyuxoOGjtNng==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-direction": "1.1.0", + "@radix-ui/react-id": "1.1.0", + "@radix-ui/react-presence": "1.1.2", + "@radix-ui/react-primitive": "2.0.2", + "@radix-ui/react-roving-focus": "1.1.2", + "@radix-ui/react-use-controllable-state": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toggle": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle/-/react-toggle-1.1.2.tgz", + "integrity": "sha512-lntKchNWx3aCHuWKiDY+8WudiegQvBpDRAYL8dKLRvKEH8VOpl0XX6SSU/bUBqIRJbcTy4+MW06Wv8vgp10rzQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-primitive": "2.0.2", + "@radix-ui/react-use-controllable-state": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toggle-group": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle-group/-/react-toggle-group-1.1.2.tgz", + "integrity": "sha512-JBm6s6aVG/nwuY5eadhU2zDi/IwYS0sDM5ZWb4nymv/hn3hZdkw+gENn0LP4iY1yCd7+bgJaCwueMYJIU3vk4A==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-direction": "1.1.0", + "@radix-ui/react-primitive": "2.0.2", + "@radix-ui/react-roving-focus": "1.1.2", + "@radix-ui/react-toggle": "1.1.2", + "@radix-ui/react-use-controllable-state": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tooltip": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.1.8.tgz", + "integrity": "sha512-YAA2cu48EkJZdAMHC0dqo9kialOcRStbtiY4nJPaht7Ptrhcvpo+eDChaM6BIs8kL6a8Z5l5poiqLnXcNduOkA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.5", + "@radix-ui/react-id": "1.1.0", + "@radix-ui/react-popper": "1.2.2", + "@radix-ui/react-portal": "1.1.4", + "@radix-ui/react-presence": "1.1.2", + "@radix-ui/react-primitive": "2.0.2", + "@radix-ui/react-slot": "1.1.2", + "@radix-ui/react-use-controllable-state": "1.1.0", + "@radix-ui/react-visually-hidden": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.0.tgz", + "integrity": "sha512-CasTfvsy+frcFkbXtSJ2Zu9JHpN8TYKxkgJGWbjiZhFivxaeW7rMeZt7QELGVLaYVfFMsKHjb7Ak0nMEe+2Vfw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.1.0.tgz", + "integrity": "sha512-MtfMVJiSr2NjzS0Aa90NPTnvTSg6C/JLCV7ma0W6+OMV78vd8OyRpID+Ng9LxzsPbLeuBnWBA1Nq30AtBIDChw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-callback-ref": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-escape-keydown": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.0.tgz", + "integrity": "sha512-L7vwWlR1kTTQ3oh7g1O0CBF3YCyyTj8NmhLR+phShpyA50HCfBFKVJTpshm9PzLiKmehsrQzTYTpX9HvmC9rhw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-callback-ref": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.0.tgz", + "integrity": "sha512-+FPE0rOdziWSrH9athwI1R0HDVbWlEhd+FR+aSDk4uWGmSJ9Z54sdZVDQPZAinJhJXwfT+qnj969mCsT2gfm5w==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-previous": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.1.0.tgz", + "integrity": "sha512-Z/e78qg2YFnnXcW88A4JmTtm4ADckLno6F7OXotmkQfeuCVaKuYzqAATPhVzl3delXE7CxIV8shofPn3jPc5Og==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-rect": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.0.tgz", + "integrity": "sha512-0Fmkebhr6PiseyZlYAOtLS+nb7jLmpqTrJyv61Pe68MKYW6OWdRE2kI70TaYY27u7H0lajqM3hSMMLFq18Z7nQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/rect": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-size": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.0.tgz", + "integrity": "sha512-XW3/vWuIXHa+2Uwcc2ABSfcCledmXhhQPlGbfcRXbiUQI5Icjcg19BGCZVKKInYbvUCut/ufbbLLPFC5cbb1hw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-visually-hidden": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.1.2.tgz", + "integrity": "sha512-1SzA4ns2M1aRlvxErqhLHsBHoS5eI5UUcI2awAMgGUp4LoaoWOKYmvqDY2s/tltuPkh3Yk77YF/r3IRj+Amx4Q==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.0.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/rect": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.0.tgz", + "integrity": "sha512-A9+lCBZoaMJlVKcRBz2YByCG+Cp2t6nAnMnNba+XiWxnj6r4JUFqfsgwocMBZU9LPtdxC6wB56ySYpc7LQIoJg==", + "license": "MIT" + }, + "node_modules/@react-dnd/asap": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@react-dnd/asap/-/asap-5.0.2.tgz", + "integrity": "sha512-WLyfoHvxhs0V9U+GTsGilGgf2QsPl6ZZ44fnv0/b8T3nQyvzxidxsg/ZltbWssbsRDlYW8UKSQMTGotuTotZ6A==", + "license": "MIT" + }, + "node_modules/@react-dnd/invariant": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@react-dnd/invariant/-/invariant-4.0.2.tgz", + "integrity": "sha512-xKCTqAK/FFauOM9Ta2pswIyT3D8AQlfrYdOi/toTPEhqCuAs1v5tcJ3Y08Izh1cJ5Jchwy9SeAXmMg6zrKs2iw==", + "license": "MIT" + }, + "node_modules/@react-dnd/shallowequal": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@react-dnd/shallowequal/-/shallowequal-4.0.2.tgz", + "integrity": "sha512-/RVXdLvJxLg4QKvMoM5WlwNR9ViO9z8B/qPcc+C0Sa/teJY7QG7kJ441DwzOjMYEY7GmU4dj5EcGHIkKZiQZCA==", + "license": "MIT" + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.4.tgz", + "integrity": "sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.4.tgz", + "integrity": "sha512-GxxTKApUpzRhof7poWvCJHRF51C67u1R7D6DiluBE8wKU1u5GWE8t+v81JvJYtbawoBFX1hLv5Ei4eVjkWokaw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.4.tgz", + "integrity": "sha512-tua0TaJxMOB1R0V0RS1jFZ/RpURFDJIOR2A6jWwQeawuFyS4gBW+rntLRaQd0EQ4bd6Vp44Z2rXW+YYDBsj6IA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.4.tgz", + "integrity": "sha512-CSKq7MsP+5PFIcydhAiR1K0UhEI1A2jWXVKHPCBZ151yOutENwvnPocgVHkivu2kviURtCEB6zUQw0vs8RrhMg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.4.tgz", + "integrity": "sha512-+O8OkVdyvXMtJEciu2wS/pzm1IxntEEQx3z5TAVy4l32G0etZn+RsA48ARRrFm6Ri8fvqPQfgrvNxSjKAbnd3g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.4.tgz", + "integrity": "sha512-Iw3oMskH3AfNuhU0MSN7vNbdi4me/NiYo2azqPz/Le16zHSa+3RRmliCMWWQmh4lcndccU40xcJuTYJZxNo/lw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.4.tgz", + "integrity": "sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.4.tgz", + "integrity": "sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.4.tgz", + "integrity": "sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.4.tgz", + "integrity": "sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.4.tgz", + "integrity": "sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.4.tgz", + "integrity": "sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.4.tgz", + "integrity": "sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.4.tgz", + "integrity": "sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.4.tgz", + "integrity": "sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.4.tgz", + "integrity": "sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.4.tgz", + "integrity": "sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.4.tgz", + "integrity": "sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.4.tgz", + "integrity": "sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.4.tgz", + "integrity": "sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.4.tgz", + "integrity": "sha512-IPOsh5aRYuLv/nkU51X10Bf75Bsf6+gZdx1X+QP5QM6lIJFHHqbHLG0uJn/hWthzo13UAc2umiUorqZy3axoZg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.4.tgz", + "integrity": "sha512-4QzE9E81OohJ/HKzHhsqU+zcYYojVOXlFMs1DdyMT6qXl/niOH7AVElmmEdUNHHS/oRkc++d5k6Vy85zFs0DEw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.4.tgz", + "integrity": "sha512-zTPgT1YuHHcd+Tmx7h8aml0FWFVelV5N54oHow9SLj+GfoDy/huQ+UV396N/C7KpMDMiPspRktzM1/0r1usYEA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.4.tgz", + "integrity": "sha512-DRS4G7mi9lJxqEDezIkKCaUIKCrLUUDCUaCsTPCi/rtqaC6D/jjwslMQyiDU50Ka0JKpeXeRBFBAXwArY52vBw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.4.tgz", + "integrity": "sha512-QVTUovf40zgTqlFVrKA1uXMVvU2QWEFWfAH8Wdc48IxLvrJMQVMBRjuQyUpzZCDkakImib9eVazbWlC6ksWtJw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tailwindcss/node": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.12.tgz", + "integrity": "sha512-3hm9brwvQkZFe++SBt+oLjo4OLDtkvlE8q2WalaD/7QWaeM7KEJbAiY/LJZUaCs7Xa8aUu4xy3uoyX4q54UVdQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.4", + "enhanced-resolve": "^5.18.3", + "jiti": "^2.5.1", + "lightningcss": "1.30.1", + "magic-string": "^0.30.17", + "source-map-js": "^1.2.1", + "tailwindcss": "4.1.12" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.12.tgz", + "integrity": "sha512-gM5EoKHW/ukmlEtphNwaGx45fGoEmP10v51t9unv55voWh6WrOL19hfuIdo2FjxIaZzw776/BUQg7Pck++cIVw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.4", + "tar": "^7.4.3" + }, + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.1.12", + "@tailwindcss/oxide-darwin-arm64": "4.1.12", + "@tailwindcss/oxide-darwin-x64": "4.1.12", + "@tailwindcss/oxide-freebsd-x64": "4.1.12", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.12", + "@tailwindcss/oxide-linux-arm64-gnu": "4.1.12", + "@tailwindcss/oxide-linux-arm64-musl": "4.1.12", + "@tailwindcss/oxide-linux-x64-gnu": "4.1.12", + "@tailwindcss/oxide-linux-x64-musl": "4.1.12", + "@tailwindcss/oxide-wasm32-wasi": "4.1.12", + "@tailwindcss/oxide-win32-arm64-msvc": "4.1.12", + "@tailwindcss/oxide-win32-x64-msvc": "4.1.12" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.12.tgz", + "integrity": "sha512-oNY5pq+1gc4T6QVTsZKwZaGpBb2N1H1fsc1GD4o7yinFySqIuRZ2E4NvGasWc6PhYJwGK2+5YT1f9Tp80zUQZQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.12.tgz", + "integrity": "sha512-cq1qmq2HEtDV9HvZlTtrj671mCdGB93bVY6J29mwCyaMYCP/JaUBXxrQQQm7Qn33AXXASPUb2HFZlWiiHWFytw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.12.tgz", + "integrity": "sha512-6UCsIeFUcBfpangqlXay9Ffty9XhFH1QuUFn0WV83W8lGdX8cD5/+2ONLluALJD5+yJ7k8mVtwy3zMZmzEfbLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.12.tgz", + "integrity": "sha512-JOH/f7j6+nYXIrHobRYCtoArJdMJh5zy5lr0FV0Qu47MID/vqJAY3r/OElPzx1C/wdT1uS7cPq+xdYYelny1ww==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.12.tgz", + "integrity": "sha512-v4Ghvi9AU1SYgGr3/j38PD8PEe6bRfTnNSUE3YCMIRrrNigCFtHZ2TCm8142X8fcSqHBZBceDx+JlFJEfNg5zQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.12.tgz", + "integrity": "sha512-YP5s1LmetL9UsvVAKusHSyPlzSRqYyRB0f+Kl/xcYQSPLEw/BvGfxzbH+ihUciePDjiXwHh+p+qbSP3SlJw+6g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.12.tgz", + "integrity": "sha512-V8pAM3s8gsrXcCv6kCHSuwyb/gPsd863iT+v1PGXC4fSL/OJqsKhfK//v8P+w9ThKIoqNbEnsZqNy+WDnwQqCA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.12.tgz", + "integrity": "sha512-xYfqYLjvm2UQ3TZggTGrwxjYaLB62b1Wiysw/YE3Yqbh86sOMoTn0feF98PonP7LtjsWOWcXEbGqDL7zv0uW8Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.12.tgz", + "integrity": "sha512-ha0pHPamN+fWZY7GCzz5rKunlv9L5R8kdh+YNvP5awe3LtuXb5nRi/H27GeL2U+TdhDOptU7T6Is7mdwh5Ar3A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.12.tgz", + "integrity": "sha512-4tSyu3dW+ktzdEpuk6g49KdEangu3eCYoqPhWNsZgUhyegEda3M9rG0/j1GV/JjVVsj+lG7jWAyrTlLzd/WEBg==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.4.5", + "@emnapi/runtime": "^1.4.5", + "@emnapi/wasi-threads": "^1.0.4", + "@napi-rs/wasm-runtime": "^0.2.12", + "@tybys/wasm-util": "^0.10.0", + "tslib": "^2.8.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.4.5", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.0.4", + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.4.5", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": { + "version": "1.0.4", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { + "version": "0.2.12", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.4.3", + "@emnapi/runtime": "^1.4.3", + "@tybys/wasm-util": "^0.10.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": { + "version": "0.10.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": { + "version": "2.8.0", + "dev": true, + "inBundle": true, + "license": "0BSD", + "optional": true + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.12.tgz", + "integrity": "sha512-iGLyD/cVP724+FGtMWslhcFyg4xyYyM+5F4hGvKA7eifPkXHRAUDFaimu53fpNg9X8dfP75pXx/zFt/jlNF+lg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.12.tgz", + "integrity": "sha512-NKIh5rzw6CpEodv/++r0hGLlfgT/gFN+5WNdZtvh6wpU2BpGNgdjvj6H2oFc8nCM839QM1YOhjpgbAONUb4IxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.1.12.tgz", + "integrity": "sha512-4pt0AMFDx7gzIrAOIYgYP0KCBuKWqyW8ayrdiLEjoJTT4pKTjrzG/e4uzWtTLDziC+66R9wbUqZBccJalSE5vQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.1.12", + "@tailwindcss/oxide": "4.1.12", + "tailwindcss": "4.1.12" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/jszip": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/@types/jszip/-/jszip-3.4.1.tgz", + "integrity": "sha512-TezXjmf3lj+zQ651r6hPqvSScqBLvyPI9FxdXBqpEwBijNGQ2NXpaFW/7joGzveYkKQUil7iiDHLo6LV71Pc0A==", + "deprecated": "This is a stub types definition. jszip provides its own type definitions, so you do not need this installed.", + "license": "MIT", + "dependencies": { + "jszip": "*" + } + }, + "node_modules/@types/parse-json": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz", + "integrity": "sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==", + "license": "MIT" + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "19.2.15", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.15.tgz", + "integrity": "sha512-eRwcGNHve+E8qtEQSSRl6urh+rFop4v8gm6O8rGv25CodbvFdLjA1vVQ1KkiFE0w0UPOnb8tDiFKL5lp0rtY5Q==", + "license": "MIT", + "peer": true, + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-transition-group": { + "version": "4.4.12", + "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.12.tgz", + "integrity": "sha512-8TV6R3h2j7a91c+1DXdJi3Syo69zzIZbz7Lg5tORM5LEJG7X/E6a1V3drRyBRZq7/utz7A+c4OgYLiLcYGHG6w==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/@vitest/expect": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.8.tgz", + "integrity": "sha512-h3nDO677RDLEGlBxyQ5CW8RlMThSKSRLUePLOx09gNIWRL40edgA1GCZSZgf1W55MFAG6/Sw14KeaAnqv0NKdQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.8", + "@vitest/utils": "4.1.8", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.8.tgz", + "integrity": "sha512-LEiN/xe4OSIbKe9HQIp5OC24agGD9J5CnmMgsLohVVoOPWL9a2sBoR6VBx43jQZb7Kr1l4RCuyCJzcAa0+dojw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.8", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.8.tgz", + "integrity": "sha512-9GasEBxpZ1VYIpqHf/0+YGg121uSNwCKOJqIrTwWP/TB7DmFCiaBpNl3aPZzoLWfWkuqhbH8vJIVobZkvdo2cA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.8.tgz", + "integrity": "sha512-EmVxeBAfMJvycdjd6Hm+RbFBbA9fKvo0Kx37hNpBYoYeavH3RNsBXWDooR1mgD52dCrxIIuP7UotpfiwOikvcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.8", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.8.tgz", + "integrity": "sha512-acfZboRmAIf05DEKcBQy33VXojFJjtUdLyo7oOmV9kebb2xdU01UknNiPuPZoJZQyO7DF0gZdTGTpeAzET9QPQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.8", + "@vitest/utils": "4.1.8", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.8.tgz", + "integrity": "sha512-6EevtBp6OZOPF7bmz36HrGMeP3txgVSrgebWxHOafDXGkhIzfXK14f8KF6MuFfgXXUeHxmpD3BQxkV00/3s5mA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.8.tgz", + "integrity": "sha512-uOJamYALNhfJ6iolExyQM40yIQwDqYnkKtQ5VCiSe17E33H0aQ/u+1GlRuz4LZBk6Mm3sg90G9hEbmEt37C1Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.8", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils/node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/aria-hidden": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz", + "integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/babel-plugin-macros": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/babel-plugin-macros/-/babel-plugin-macros-3.1.0.tgz", + "integrity": "sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5", + "cosmiconfig": "^7.0.0", + "resolve": "^1.19.0" + }, + "engines": { + "node": ">=10", + "npm": ">=6" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.33", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.33.tgz", + "integrity": "sha512-bA6+tcSLpz2tIEdDXZPpPTIuxBcC4+w6SieaYyfigIa4h8GlFxbA17v22Vx3JUtuZQj9SgOsnbK+aTBzyDyEuw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001793", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001793.tgz", + "integrity": "sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/canvas-confetti": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/canvas-confetti/-/canvas-confetti-1.9.4.tgz", + "integrity": "sha512-yxQbJkAVrFXWNbTUjPqjF7G+g6pDotOUHGbkZq2NELZUMDpiJ85rIEazVb8GTaAptNW2miJAXbs1BtioA251Pw==", + "license": "ISC", + "funding": { + "type": "donate", + "url": "https://www.paypal.me/kirilvatev" + } + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/chownr": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", + "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/class-variance-authority": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", + "integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==", + "license": "Apache-2.0", + "dependencies": { + "clsx": "^2.1.1" + }, + "funding": { + "url": "https://polar.sh/cva" + } + }, + "node_modules/classnames": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz", + "integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==", + "license": "MIT" + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/cmdk": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cmdk/-/cmdk-1.1.1.tgz", + "integrity": "sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "^1.1.1", + "@radix-ui/react-dialog": "^1.1.6", + "@radix-ui/react-id": "^1.1.0", + "@radix-ui/react-primitive": "^2.0.2" + }, + "peerDependencies": { + "react": "^18 || ^19 || ^19.0.0-rc", + "react-dom": "^18 || ^19 || ^19.0.0-rc" + } + }, + "node_modules/convert-source-map": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", + "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==", + "license": "MIT" + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "license": "MIT" + }, + "node_modules/cosmiconfig": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz", + "integrity": "sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==", + "license": "MIT", + "dependencies": { + "@types/parse-json": "^4.0.0", + "import-fresh": "^3.2.1", + "parse-json": "^5.0.0", + "path-type": "^4.0.0", + "yaml": "^1.10.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/cosmiconfig/node_modules/yaml": { + "version": "1.10.3", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.3.tgz", + "integrity": "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==", + "license": "ISC", + "engines": { + "node": ">= 6" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/date-fns": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-3.6.0.tgz", + "integrity": "sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/detect-node-es": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", + "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", + "license": "MIT" + }, + "node_modules/dnd-core": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/dnd-core/-/dnd-core-16.0.1.tgz", + "integrity": "sha512-HK294sl7tbw6F6IeuK16YSBUoorvHpY8RHO+9yFfaJyCDVb6n7PRcezrOEOa2SBCqiYpemh5Jx20ZcjKdFAVng==", + "license": "MIT", + "dependencies": { + "@react-dnd/asap": "^5.0.1", + "@react-dnd/invariant": "^4.0.1", + "redux": "^4.2.0" + } + }, + "node_modules/dom-helpers": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", + "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.8.7", + "csstype": "^3.0.2" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.364", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.364.tgz", + "integrity": "sha512-G/dYE3+AYhyHwzTwg8UbnXf7zqMERYh7l2jJ3QujhFsH8agSYwtnGAR2aZ7f0AakIKJXd5En/Hre4igIUrdlYw==", + "dev": true, + "license": "ISC" + }, + "node_modules/embla-carousel": { + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/embla-carousel/-/embla-carousel-8.6.0.tgz", + "integrity": "sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA==", + "license": "MIT" + }, + "node_modules/embla-carousel-react": { + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/embla-carousel-react/-/embla-carousel-react-8.6.0.tgz", + "integrity": "sha512-0/PjqU7geVmo6F734pmPqpyHqiM99olvyecY7zdweCw+6tKEXnrE90pBiBbMMU8s5tICemzpQ3hi5EpxzGW+JA==", + "license": "MIT", + "dependencies": { + "embla-carousel": "8.6.0", + "embla-carousel-reactive-utils": "8.6.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.1 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + } + }, + "node_modules/embla-carousel-reactive-utils": { + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/embla-carousel-reactive-utils/-/embla-carousel-reactive-utils-8.6.0.tgz", + "integrity": "sha512-fMVUDUEx0/uIEDM0Mz3dHznDhfX+znCCDCeIophYb1QGVM7YThSWX+wz11zlYwWFOr74b4QLGg0hrGPJeG2s4A==", + "license": "MIT", + "peerDependencies": { + "embla-carousel": "8.6.0" + } + }, + "node_modules/enhanced-resolve": { + "version": "5.22.1", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.22.1.tgz", + "integrity": "sha512-6QEuw3zoX1SJQc7b87aBXke/no+mG2bTBgw29gWMQonLmpEkWoCAVkl+M49e48AZlWzxiDzDZzYdp6kobcyLww==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/error-ex": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", + "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", + "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", + "license": "MIT" + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-equals": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.0.tgz", + "integrity": "sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/find-root": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/find-root/-/find-root-1.1.0.tgz", + "integrity": "sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==", + "license": "MIT" + }, + "node_modules/framer-motion": { + "version": "12.40.0", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.40.0.tgz", + "integrity": "sha512-uaBd3qC1v3KQqBEjwTUd183K6PbS+j0yR9w9VmEOLWA/tnUcSn8Xa3uck7t4dgpDoUss8xQTcj8W2L07lrnLFg==", + "license": "MIT", + "dependencies": { + "motion-dom": "^12.40.0", + "motion-utils": "^12.39.0", + "tslib": "^2.4.0" + }, + "peerDependencies": { + "@emotion/is-prop-valid": "*", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/is-prop-valid": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-nonce": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", + "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hoist-non-react-statics": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", + "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", + "license": "BSD-3-Clause", + "dependencies": { + "react-is": "^16.7.0" + } + }, + "node_modules/hoist-non-react-statics/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", + "license": "MIT" + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/input-otp": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/input-otp/-/input-otp-1.4.2.tgz", + "integrity": "sha512-l3jWwYNvrEa6NTCt7BECfCm48GvwuZzkoeG3gBL2w4CHeOXW3eKFmf9UNYkNfYc3mxMrthMnxjIE07MT0zLBQA==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc" + } + }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "license": "MIT" + }, + "node_modules/is-core-module": { + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", + "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", + "license": "MIT", + "dependencies": { + "hasown": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "license": "MIT" + }, + "node_modules/json2mq": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/json2mq/-/json2mq-0.2.0.tgz", + "integrity": "sha512-SzoRg7ux5DWTII9J2qkrZrqV1gt+rTaoufMxEzXbS26Uid0NwaJd123HcoB80TgubEppxxIGdNxCx50fEoEWQA==", + "license": "MIT", + "dependencies": { + "string-convert": "^0.2.0" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jszip": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", + "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==", + "license": "(MIT OR GPL-3.0-or-later)", + "dependencies": { + "lie": "~3.3.0", + "pako": "~1.0.2", + "readable-stream": "~2.3.6", + "setimmediate": "^1.0.5" + } + }, + "node_modules/keycloak-js": { + "version": "26.1.1", + "resolved": "https://registry.npmjs.org/keycloak-js/-/keycloak-js-26.1.1.tgz", + "integrity": "sha512-vz1QZvg7YlytdUMiafaFDkmd4iWPEcYjSwlkFLmc6DaRtAiGjS65nTfzls9ph5bI0lMWjDjkpyZa5+J/t9+kGQ==", + "license": "Apache-2.0" + }, + "node_modules/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "license": "MIT", + "dependencies": { + "immediate": "~3.0.5" + } + }, + "node_modules/lightningcss": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.1.tgz", + "integrity": "sha512-xi6IyHML+c9+Q3W0S4fCQJOym42pyurFiJUHEcEyHS0CeKzia4yZDEsLlqOFykxOdHpNy0NmvVO31vcSqAxJCg==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-darwin-arm64": "1.30.1", + "lightningcss-darwin-x64": "1.30.1", + "lightningcss-freebsd-x64": "1.30.1", + "lightningcss-linux-arm-gnueabihf": "1.30.1", + "lightningcss-linux-arm64-gnu": "1.30.1", + "lightningcss-linux-arm64-musl": "1.30.1", + "lightningcss-linux-x64-gnu": "1.30.1", + "lightningcss-linux-x64-musl": "1.30.1", + "lightningcss-win32-arm64-msvc": "1.30.1", + "lightningcss-win32-x64-msvc": "1.30.1" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.1.tgz", + "integrity": "sha512-c8JK7hyE65X1MHMN+Viq9n11RRC7hgin3HhYKhrMyaXflk5GVplZ60IxyoVtzILeKr+xAJwg6zK6sjTBJ0FKYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.1.tgz", + "integrity": "sha512-k1EvjakfumAQoTfcXUcHQZhSpLlkAuEkdMBsI/ivWw9hL+7FtilQc0Cy3hrx0AAQrVtQAbMI7YjCgYgvn37PzA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.1.tgz", + "integrity": "sha512-kmW6UGCGg2PcyUE59K5r0kWfKPAVy4SltVeut+umLCFoJ53RdCUWxcRDzO1eTaxf/7Q2H7LTquFHPL5R+Gjyig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.1.tgz", + "integrity": "sha512-MjxUShl1v8pit+6D/zSPq9S9dQ2NPFSQwGvxBCYaBYLPlCWuPh9/t1MRS8iUaR8i+a6w7aps+B4N0S1TYP/R+Q==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.1.tgz", + "integrity": "sha512-gB72maP8rmrKsnKYy8XUuXi/4OctJiuQjcuqWNlJQ6jZiWqtPvqFziskH3hnajfvKB27ynbVCucKSm2rkQp4Bw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.1.tgz", + "integrity": "sha512-jmUQVx4331m6LIX+0wUhBbmMX7TCfjF5FoOH6SD1CttzuYlGNVpA7QnrmLxrsub43ClTINfGSYyHe2HWeLl5CQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.1.tgz", + "integrity": "sha512-piWx3z4wN8J8z3+O5kO74+yr6ze/dKmPnI7vLqfSqI8bccaTGY5xiSGVIJBDd5K5BHlvVLpUB3S2YCfelyJ1bw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.1.tgz", + "integrity": "sha512-rRomAK7eIkL+tHY0YPxbc5Dra2gXlI63HL+v1Pdi1a3sC+tJTcFrHX+E86sulgAXeI7rSzDYhPSeHHjqFhqfeQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.1.tgz", + "integrity": "sha512-mSL4rqPi4iXq5YVqzSsJgMVFENoa4nGTT/GjO2c0Yl9OuQfPsIfncvLrEW6RbbB24WtZ3xP/2CCmI3tNkNV4oA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.1.tgz", + "integrity": "sha512-PVqXh48wh4T53F/1CCu8PIPCxLzWyCnn/9T5W1Jpmdy5h9Cwd+0YQS6/LwhHXSafuc61/xg9Lv5OrCby6a++jg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "license": "MIT" + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, + "node_modules/lodash.debounce": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", + "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==", + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lucide-react": { + "version": "0.487.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.487.0.tgz", + "integrity": "sha512-aKqhOQ+YmFnwq8dWgGjOuLc8V1R9/c/yOd+zDY4+ohsR2Jo05lSGc3WsstYPIzcTpeosN7LoCkLReUUITvaIvw==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/minizlib": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", + "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "minipass": "^7.1.2" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/motion": { + "version": "12.23.24", + "resolved": "https://registry.npmjs.org/motion/-/motion-12.23.24.tgz", + "integrity": "sha512-Rc5E7oe2YZ72N//S3QXGzbnXgqNrTESv8KKxABR20q2FLch9gHLo0JLyYo2hZ238bZ9Gx6cWhj9VO0IgwbMjCw==", + "license": "MIT", + "dependencies": { + "framer-motion": "^12.23.24", + "tslib": "^2.4.0" + }, + "peerDependencies": { + "@emotion/is-prop-valid": "*", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/is-prop-valid": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, + "node_modules/motion-dom": { + "version": "12.40.0", + "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.40.0.tgz", + "integrity": "sha512-HxU3ZaBwNPVQUBQf1xxgq+7JrPNZvjLVxgbpEZL7RrWJnsxOf0/OM+yrHG9ogLQ31Do/r57Oz2gQWPK+6q62mg==", + "license": "MIT", + "dependencies": { + "motion-utils": "^12.39.0" + } + }, + "node_modules/motion-utils": { + "version": "12.39.0", + "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.39.0.tgz", + "integrity": "sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ==", + "license": "MIT" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/next-themes": { + "version": "0.4.6", + "resolved": "https://registry.npmjs.org/next-themes/-/next-themes-0.4.6.tgz", + "integrity": "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc" + } + }, + "node_modules/node-releases": { + "version": "2.0.46", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.46.tgz", + "integrity": "sha512-GYVXHE2KnrzAfsAjl4uP++evGFCrAU1jta4ubEjIG7YWt/64Gqv66a30yKwWczVjA6j3bM4nBwH7Pk1JmDHaxQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/obug": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.2.tgz", + "integrity": "sha512-AWGB9WFcRXOQs48Z/udjI5ZcZMHXwX8XPByNpOydgcGsDLIzjGizhoMWJyKAWze7AVW/2W1i+/gPX4YtKe5cyg==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "license": "(MIT AND Zlib)" + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "license": "MIT" + }, + "node_modules/path-type": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/playwright": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz", + "integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.60.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz", + "integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "license": "MIT" + }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/prop-types/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-day-picker": { + "version": "8.10.1", + "resolved": "https://registry.npmjs.org/react-day-picker/-/react-day-picker-8.10.1.tgz", + "integrity": "sha512-TMx7fNbhLk15eqcMt+7Z7S2KF7mfTId/XJDjKE8f+IUcFn0l08/kI4FiYTL/0yuOLmEcbR4Fwe3GJf/NiiMnPA==", + "license": "MIT", + "funding": { + "type": "individual", + "url": "https://github.com/sponsors/gpbl" + }, + "peerDependencies": { + "date-fns": "^2.28.0 || ^3.0.0", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, + "node_modules/react-dnd": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/react-dnd/-/react-dnd-16.0.1.tgz", + "integrity": "sha512-QeoM/i73HHu2XF9aKksIUuamHPDvRglEwdHL4jsp784BgUuWcg6mzfxT0QDdQz8Wj0qyRKx2eMg8iZtWvU4E2Q==", + "license": "MIT", + "dependencies": { + "@react-dnd/invariant": "^4.0.1", + "@react-dnd/shallowequal": "^4.0.1", + "dnd-core": "^16.0.1", + "fast-deep-equal": "^3.1.3", + "hoist-non-react-statics": "^3.3.2" + }, + "peerDependencies": { + "@types/hoist-non-react-statics": ">= 3.3.1", + "@types/node": ">= 12", + "@types/react": ">= 16", + "react": ">= 16.14" + }, + "peerDependenciesMeta": { + "@types/hoist-non-react-statics": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-dnd-html5-backend": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/react-dnd-html5-backend/-/react-dnd-html5-backend-16.0.1.tgz", + "integrity": "sha512-Wu3dw5aDJmOGw8WjH1I1/yTH+vlXEL4vmjk5p+MHxP8HuHJS1lAGeIdG/hze1AvNeXWo/JgULV87LyQOr+r5jw==", + "license": "MIT", + "dependencies": { + "dnd-core": "^16.0.1" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "peer": true, + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-fast-compare": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-3.2.2.tgz", + "integrity": "sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ==", + "license": "MIT" + }, + "node_modules/react-hook-form": { + "version": "7.55.0", + "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.55.0.tgz", + "integrity": "sha512-XRnjsH3GVMQz1moZTW53MxfoWN7aDpUg/GpVNc4A3eXRVNdGXfbzJ4vM4aLQ8g6XCUh1nIbx70aaNCl7kxnjog==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/react-hook-form" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17 || ^18 || ^19" + } + }, + "node_modules/react-is": { + "version": "19.2.6", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.6.tgz", + "integrity": "sha512-XjBR15BhXuylgWGuslhDKqlSayuqvqBX91BP8pauG8kd1zY8kotkNWbXksTCNRarse4kuGbe2kIY05ARtwNIvw==", + "license": "MIT" + }, + "node_modules/react-popper": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/react-popper/-/react-popper-2.3.0.tgz", + "integrity": "sha512-e1hj8lL3uM+sgSR4Lxzn5h1GxBlpa4CQz0XLF8kx4MDrDRWY0Ena4c97PUeSX9i5W3UAfDP0z0FXCTQkoXUl3Q==", + "license": "MIT", + "dependencies": { + "react-fast-compare": "^3.0.1", + "warning": "^4.0.2" + }, + "peerDependencies": { + "@popperjs/core": "^2.0.0", + "react": "^16.8.0 || ^17 || ^18", + "react-dom": "^16.8.0 || ^17 || ^18" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-remove-scroll": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz", + "integrity": "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==", + "license": "MIT", + "dependencies": { + "react-remove-scroll-bar": "^2.3.7", + "react-style-singleton": "^2.2.3", + "tslib": "^2.1.0", + "use-callback-ref": "^1.3.3", + "use-sidecar": "^1.1.3" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-remove-scroll-bar": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz", + "integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==", + "license": "MIT", + "dependencies": { + "react-style-singleton": "^2.2.2", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-resizable-panels": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/react-resizable-panels/-/react-resizable-panels-2.1.7.tgz", + "integrity": "sha512-JtT6gI+nURzhMYQYsx8DKkx6bSoOGFp7A3CwMrOb8y5jFHFyqwo9m68UhmXRw57fRVJksFn1TSlm3ywEQ9vMgA==", + "license": "MIT", + "peerDependencies": { + "react": "^16.14.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc", + "react-dom": "^16.14.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + } + }, + "node_modules/react-responsive-masonry": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/react-responsive-masonry/-/react-responsive-masonry-2.7.1.tgz", + "integrity": "sha512-Q+u+nOH87PzjqGFd2PgTcmLpHPZnCmUPREHYoNBc8dwJv6fi51p9U6hqwG8g/T8MN86HrFjrU+uQU6yvETU7cA==", + "license": "MIT" + }, + "node_modules/react-router": { + "version": "7.17.0", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.17.0.tgz", + "integrity": "sha512-FDELK7rTMlCHO5+reyXsPlmfr7N1F91lPHsWYfMEGQm/KQ+F4JFM8jGoeQDmDvdTs93Fw9aSilH+uKRb4/jXvQ==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-slick": { + "version": "0.31.0", + "resolved": "https://registry.npmjs.org/react-slick/-/react-slick-0.31.0.tgz", + "integrity": "sha512-zo6VLT8wuSBJffg/TFPbzrw2dEnfZ/cUKmYsKByh3AgatRv29m2LoFbq5vRMa3R3A4wp4d8gwbJKO2fWZFaI3g==", + "license": "MIT", + "dependencies": { + "classnames": "^2.2.5", + "json2mq": "^0.2.0", + "lodash.debounce": "^4.0.8", + "resize-observer-polyfill": "^1.5.0" + }, + "peerDependencies": { + "react": "^0.14.0 || ^15.0.1 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^0.14.0 || ^15.0.1 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/react-smooth": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.4.tgz", + "integrity": "sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==", + "license": "MIT", + "dependencies": { + "fast-equals": "^5.0.1", + "prop-types": "^15.8.1", + "react-transition-group": "^4.4.5" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/react-style-singleton": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz", + "integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==", + "license": "MIT", + "dependencies": { + "get-nonce": "^1.0.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-transition-group": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", + "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==", + "license": "BSD-3-Clause", + "dependencies": { + "@babel/runtime": "^7.5.5", + "dom-helpers": "^5.0.1", + "loose-envify": "^1.4.0", + "prop-types": "^15.6.2" + }, + "peerDependencies": { + "react": ">=16.6.0", + "react-dom": ">=16.6.0" + } + }, + "node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/recharts": { + "version": "2.15.2", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.2.tgz", + "integrity": "sha512-xv9lVztv3ingk7V3Jf05wfAZbM9Q2umJzu5t/cfnAK7LUslNrGT7LPBr74G+ok8kSCeFMaePmWMg0rcYOnczTw==", + "deprecated": "1.x and 2.x branches are no longer active. Bump to Recharts v3 to receive latest features and bugfixes. See https://github.com/recharts/recharts/wiki/3.0-migration-guide", + "license": "MIT", + "dependencies": { + "clsx": "^2.0.0", + "eventemitter3": "^4.0.1", + "lodash": "^4.17.21", + "react-is": "^18.3.1", + "react-smooth": "^4.0.4", + "recharts-scale": "^0.4.4", + "tiny-invariant": "^1.3.1", + "victory-vendor": "^36.6.8" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/recharts-scale": { + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/recharts-scale/-/recharts-scale-0.4.5.tgz", + "integrity": "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==", + "license": "MIT", + "dependencies": { + "decimal.js-light": "^2.4.1" + } + }, + "node_modules/recharts/node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "license": "MIT" + }, + "node_modules/redux": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-4.2.1.tgz", + "integrity": "sha512-LAUYz4lc+Do8/g7aeRa8JkyDErK6ekstQaqWQrNRW//MY1TvCEpMtpTWvlQ+FPbWCx+Xixu/6SHt5N0HR+SB4w==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.9.2" + } + }, + "node_modules/resize-observer-polyfill": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz", + "integrity": "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==", + "license": "MIT" + }, + "node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/rollup": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.4.tgz", + "integrity": "sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.4", + "@rollup/rollup-android-arm64": "4.60.4", + "@rollup/rollup-darwin-arm64": "4.60.4", + "@rollup/rollup-darwin-x64": "4.60.4", + "@rollup/rollup-freebsd-arm64": "4.60.4", + "@rollup/rollup-freebsd-x64": "4.60.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.4", + "@rollup/rollup-linux-arm-musleabihf": "4.60.4", + "@rollup/rollup-linux-arm64-gnu": "4.60.4", + "@rollup/rollup-linux-arm64-musl": "4.60.4", + "@rollup/rollup-linux-loong64-gnu": "4.60.4", + "@rollup/rollup-linux-loong64-musl": "4.60.4", + "@rollup/rollup-linux-ppc64-gnu": "4.60.4", + "@rollup/rollup-linux-ppc64-musl": "4.60.4", + "@rollup/rollup-linux-riscv64-gnu": "4.60.4", + "@rollup/rollup-linux-riscv64-musl": "4.60.4", + "@rollup/rollup-linux-s390x-gnu": "4.60.4", + "@rollup/rollup-linux-x64-gnu": "4.60.4", + "@rollup/rollup-linux-x64-musl": "4.60.4", + "@rollup/rollup-openbsd-x64": "4.60.4", + "@rollup/rollup-openharmony-arm64": "4.60.4", + "@rollup/rollup-win32-arm64-msvc": "4.60.4", + "@rollup/rollup-win32-ia32-msvc": "4.60.4", + "@rollup/rollup-win32-x64-gnu": "4.60.4", + "@rollup/rollup-win32-x64-msvc": "4.60.4", + "fsevents": "~2.3.2" + } + }, + "node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, + "node_modules/setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", + "license": "MIT" + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/sonner": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.3.tgz", + "integrity": "sha512-njQ4Hht92m0sMqqHVDL32V2Oun9W1+PHO9NDv9FHfJjT3JT22IG4Jpo3FPQy+mouRKCXFWO+r67v6MrHX2zeIA==", + "license": "MIT", + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", + "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" + } + }, + "node_modules/source-map": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", + "integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", + "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/string-convert": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/string-convert/-/string-convert-0.2.1.tgz", + "integrity": "sha512-u/1tdPl4yQnPBjnVrmdLo9gtuLvELKsAoRapekWggdiQNvvvum+jYF329d84NAa660KQw7pB2n36KrIKVoXa3A==", + "license": "MIT" + }, + "node_modules/stylis": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.2.0.tgz", + "integrity": "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==", + "license": "MIT" + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tailwind-merge": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.2.0.tgz", + "integrity": "sha512-FQT/OVqCD+7edmmJpsgCsY820RTD5AkBryuG5IUqR5YQZSdj5xlH5nLgH7YPths7WsLPSpSBNneJdM8aS8aeFA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, + "node_modules/tailwindcss": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.12.tgz", + "integrity": "sha512-DzFtxOi+7NsFf7DBtI3BJsynR+0Yp6etH+nRPTbpWnS2pZBaSksv/JGctNwSWzbFjp0vxSqknaUylseZqMDGrA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tar": { + "version": "7.5.15", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.15.tgz", + "integrity": "sha512-dzGK0boVlC4W5QFuQN1EFSl3bIDYsk7Tj40U6eIBnK2k/8ml7TZ5agbI5j5+qnoVcAA+rNtBml8SEiLxZpNqRQ==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.1.0", + "yallist": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/tar/node_modules/yallist": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", + "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/tw-animate-css": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/tw-animate-css/-/tw-animate-css-1.3.8.tgz", + "integrity": "sha512-Qrk3PZ7l7wUcGYhwZloqfkWCmaXZAoqjkdbIDvzfGshwGtexa/DAs9koXxIkrpEasyevandomzCBAV1Yyop5rw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Wombosvideo" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/use-callback-ref": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz", + "integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-sidecar": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz", + "integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==", + "license": "MIT", + "dependencies": { + "detect-node-es": "^1.1.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/vaul": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vaul/-/vaul-1.1.2.tgz", + "integrity": "sha512-ZFkClGpWyI2WUQjdLJ/BaGuV6AVQiJ3uELGk3OYtP+B6yCO7Cmn9vPFXVJkRaGkOJu3m8bQMgtyzNHixULceQA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-dialog": "^1.1.1" + }, + "peerDependencies": { + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc" + } + }, + "node_modules/victory-vendor": { + "version": "36.9.2", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz", + "integrity": "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, + "node_modules/vite": { + "version": "6.4.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", + "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.8.tgz", + "integrity": "sha512-flY6ScbCIt9HThs+C5HS7jvGOB560DJtk/Z15IQROTA6zEy49Nh8T/dofWTQL+n3vswqn87sbJNiuqw1SDp5Ig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.8", + "@vitest/mocker": "4.1.8", + "@vitest/pretty-format": "4.1.8", + "@vitest/runner": "4.1.8", + "@vitest/snapshot": "4.1.8", + "@vitest/spy": "4.1.8", + "@vitest/utils": "4.1.8", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.8", + "@vitest/browser-preview": "4.1.8", + "@vitest/browser-webdriverio": "4.1.8", + "@vitest/coverage-istanbul": "4.1.8", + "@vitest/coverage-v8": "4.1.8", + "@vitest/ui": "4.1.8", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/warning": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz", + "integrity": "sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.0.0" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/Frontend/package.json b/Frontend/package.json new file mode 100644 index 0000000..498e402 --- /dev/null +++ b/Frontend/package.json @@ -0,0 +1,101 @@ +{ + "name": "@figma/my-make-file", + "private": true, + "version": "0.0.1", + "type": "module", + "scripts": { + "build": "vite build", + "dev": "vite --host", + "test": "vitest", + "test:run": "vitest run", + "test:e2e": "VITE_E2E_AUTH_MODE=mock playwright test", + "test:e2e:ui": "VITE_E2E_AUTH_MODE=mock playwright test --ui", + "test:e2e:headed": "VITE_E2E_AUTH_MODE=mock playwright test --headed", + "test:e2e:report": "playwright show-report" + }, + "dependencies": { + "@emotion/react": "11.14.0", + "@emotion/styled": "11.14.1", + "@mui/icons-material": "7.3.5", + "@mui/material": "7.3.5", + "@popperjs/core": "2.11.8", + "@radix-ui/react-accordion": "1.2.3", + "@radix-ui/react-alert-dialog": "1.1.6", + "@radix-ui/react-aspect-ratio": "1.1.2", + "@radix-ui/react-avatar": "1.1.3", + "@radix-ui/react-checkbox": "1.1.4", + "@radix-ui/react-collapsible": "1.1.3", + "@radix-ui/react-context-menu": "2.2.6", + "@radix-ui/react-dialog": "1.1.6", + "@radix-ui/react-dropdown-menu": "2.1.6", + "@radix-ui/react-hover-card": "1.1.6", + "@radix-ui/react-label": "2.1.2", + "@radix-ui/react-menubar": "1.1.6", + "@radix-ui/react-navigation-menu": "1.2.5", + "@radix-ui/react-popover": "1.1.6", + "@radix-ui/react-progress": "1.1.2", + "@radix-ui/react-radio-group": "1.2.3", + "@radix-ui/react-scroll-area": "1.2.3", + "@radix-ui/react-select": "2.1.6", + "@radix-ui/react-separator": "1.1.2", + "@radix-ui/react-slider": "1.2.3", + "@radix-ui/react-slot": "1.1.2", + "@radix-ui/react-switch": "1.1.3", + "@radix-ui/react-tabs": "1.1.3", + "@radix-ui/react-toggle": "1.1.2", + "@radix-ui/react-toggle-group": "1.1.2", + "@radix-ui/react-tooltip": "1.1.8", + "@types/jszip": "^3.4.1", + "canvas-confetti": "1.9.4", + "class-variance-authority": "0.7.1", + "clsx": "2.1.1", + "cmdk": "1.1.1", + "date-fns": "3.6.0", + "embla-carousel-react": "8.6.0", + "input-otp": "1.4.2", + "jszip": "^3.10.1", + "keycloak-js": "26.1.1", + "lucide-react": "0.487.0", + "motion": "12.23.24", + "next-themes": "0.4.6", + "react-day-picker": "8.10.1", + "react-dnd": "16.0.1", + "react-dnd-html5-backend": "16.0.1", + "react-hook-form": "7.55.0", + "react-popper": "2.3.0", + "react-resizable-panels": "2.1.7", + "react-responsive-masonry": "2.7.1", + "react-router": "7.17.0", + "react-slick": "0.31.0", + "recharts": "2.15.2", + "sonner": "2.0.3", + "tailwind-merge": "3.2.0", + "tw-animate-css": "1.3.8", + "vaul": "1.1.2" + }, + "devDependencies": { + "@playwright/test": "^1.60.0", + "@tailwindcss/vite": "4.1.12", + "@vitejs/plugin-react": "4.7.0", + "tailwindcss": "4.1.12", + "vite": "6.4.3", + "vitest": "4.1.8" + }, + "peerDependencies": { + "react": "18.3.1", + "react-dom": "18.3.1" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + }, + "pnpm": { + "overrides": { + "vite": "6.4.3" + } + } +} diff --git a/Frontend/postcss.config.mjs b/Frontend/postcss.config.mjs new file mode 100644 index 0000000..531dbec --- /dev/null +++ b/Frontend/postcss.config.mjs @@ -0,0 +1,15 @@ +/** + * PostCSS Configuration + * + * Tailwind CSS v4 (via @tailwindcss/vite) automatically sets up all required + * PostCSS plugins — you do NOT need to include `tailwindcss` or `autoprefixer` here. + * + * This file only exists for adding additional PostCSS plugins, if needed. + * For example: + * + * import postcssNested from 'postcss-nested' + * export default { plugins: [postcssNested()] } + * + * Otherwise, you can leave this file empty. + */ +export default {} diff --git a/Frontend/src/app/App.tsx b/Frontend/src/app/App.tsx new file mode 100644 index 0000000..2697e3e --- /dev/null +++ b/Frontend/src/app/App.tsx @@ -0,0 +1,16 @@ +import { RouterProvider } from 'react-router' +import { router } from './routes/index' +import { ConsentManager } from './components/ConsentManager' + +// Re-export all types so existing component imports from '../App' continue to work +export type { ThemeConfig, CanvasBlock, CustomBlockDef, Page, DataSource, DataCacheEntry } from './types' +export { findBlockInTree, PRESET_THEMES, DEFAULT_THEME } from './cms-utils' + +export default function App() { + return ( + <> + + + + ) +} diff --git a/Frontend/src/app/admin-assets.ts b/Frontend/src/app/admin-assets.ts new file mode 100644 index 0000000..acc977d --- /dev/null +++ b/Frontend/src/app/admin-assets.ts @@ -0,0 +1,164 @@ +import { apiRequest } from './api/client' + +export type AssetStatus = 'active' | 'archived' | 'quarantined' | 'disabled' | 'deleted' | 'unknown' + +export type AssetRecord = { + id: string + title: string + status: AssetStatus + visibility: string + owner: string + licence: string + protectionLevel: string + relatedResource: string + copyrightMetadata: Record +} + +export type AssetAccessEvent = { + id: string + action: string + result: string + reason: string + createdAt: number | null + actor: string +} + +export type AssetClaim = { + id: string + claimType: string + status: string + claimant: string + submittedAt: number | null +} + +function asRecord(value: unknown): Record { + if (typeof value === 'object' && value !== null) return value as Record + return {} +} + +function asString(value: unknown): string { + return typeof value === 'string' ? value.trim() : '' +} + +function asTimestamp(value: unknown): number | null { + if (typeof value === 'number' && Number.isFinite(value)) return value + if (typeof value === 'string' && value.trim()) { + const parsed = Date.parse(value) + if (!Number.isNaN(parsed)) return parsed + } + return null +} + +function normalizeStatus(value: unknown): AssetStatus { + const status = asString(value).toLowerCase() + if (status === 'active' || status === 'archived' || status === 'quarantined' || status === 'disabled' || status === 'deleted') { + return status + } + return 'unknown' +} + +function normalizeRelatedResource(raw: Record): string { + const type = asString(raw.relatedResourceType) + const id = asString(raw.relatedResourceId) + if (!type && !id) return 'Not linked' + if (!type) return id + if (!id) return type + return `${type}:${id}` +} + +export function normalizeAssetRecord(raw: unknown): AssetRecord | null { + const source = asRecord(raw) + const id = asString(source.id) + if (!id) return null + + const copyright = asRecord(source.copyright) + const owner = asString(source.ownerName) || asString(source.ownerUserId) || asString(copyright.ownerName) || asString(copyright.ownerUserId) || 'Unknown' + + return { + id, + title: asString(source.title) || asString(source.filename) || `asset-${id}`, + status: normalizeStatus(source.status), + visibility: asString(source.visibility) || 'unknown', + owner, + licence: asString(copyright.licence) || asString(copyright.license) || 'unknown', + protectionLevel: asString(source.protectionLevel) || 'unknown', + relatedResource: normalizeRelatedResource(source), + copyrightMetadata: copyright, + } +} + +export async function loadAdminAssets(): Promise { + const rows = await apiRequest('/api/assets/') + return rows + .map(normalizeAssetRecord) + .filter((row): row is AssetRecord => row !== null) + .sort((a, b) => a.title.localeCompare(b.title)) +} + +export async function loadAssetAccessEvents(assetID: string): Promise { + const rows = await apiRequest(`/api/assets/${assetID}/access-events`) + return rows + .map(raw => { + const source = asRecord(raw) + const id = asString(source.id) + if (!id) return null + return { + id, + action: asString(source.action) || 'unknown', + result: asString(source.result) || 'unknown', + reason: asString(source.reason) || 'n/a', + createdAt: asTimestamp(source.createdAt), + actor: asString(source.userId) || asString(source.actor) || 'unknown', + } + }) + .filter((row): row is AssetAccessEvent => row !== null) +} + +export async function loadAssetClaims(assetID: string): Promise { + const rows = await apiRequest(`/api/assets/${assetID}/claims`) + return rows + .map(raw => { + const source = asRecord(raw) + const id = asString(source.id) + if (!id) return null + return { + id, + claimType: asString(source.claimType) || 'unknown', + status: asString(source.status) || 'unknown', + claimant: asString(source.claimantName) || asString(source.claimantEmail) || 'Unknown claimant', + submittedAt: asTimestamp(source.submittedAt), + } + }) + .filter((row): row is AssetClaim => row !== null) +} + +export async function updateAssetStatus(assetID: string, action: 'quarantine' | 'disable' | 'restore'): Promise { + const row = await apiRequest(`/api/assets/${assetID}/${action}`, { + method: 'POST', + }) + + const asset = normalizeAssetRecord(row) + if (!asset) { + throw new Error('Unexpected asset response from status update') + } + return asset +} + +export function filterAssets(records: AssetRecord[], status: 'all' | AssetStatus, query: string): AssetRecord[] { + const normalizedQuery = query.trim().toLowerCase() + + return records.filter(record => { + if (status !== 'all' && record.status !== status) { + return false + } + if (!normalizedQuery) { + return true + } + return ( + record.title.toLowerCase().includes(normalizedQuery) + || record.owner.toLowerCase().includes(normalizedQuery) + || record.relatedResource.toLowerCase().includes(normalizedQuery) + || record.licence.toLowerCase().includes(normalizedQuery) + ) + }) +} diff --git a/Frontend/src/app/admin-audit.ts b/Frontend/src/app/admin-audit.ts new file mode 100644 index 0000000..63d6805 --- /dev/null +++ b/Frontend/src/app/admin-audit.ts @@ -0,0 +1,101 @@ +import { apiRequest } from './api/client' + +export type AuditResult = 'success' | 'failed' | 'unknown' + +export type AuditEvent = { + id: string + actor: string + action: string + resource: string + result: AuditResult + timestamp: number | null + metadata: Record +} + +export type AuditFilters = { + action: string + resource: string + actor: string + result: '' | 'success' | 'failed' + since: string + until: string +} + +function asRecord(value: unknown): Record { + if (typeof value === 'object' && value !== null) return value as Record + return {} +} + +function asString(value: unknown): string { + return typeof value === 'string' ? value.trim() : '' +} + +function normalizeResult(value: string): AuditResult { + const normalized = value.toLowerCase() + if (normalized === 'success') return 'success' + if (normalized === 'failed') return 'failed' + return 'unknown' +} + +function normalizeTimestamp(value: unknown): number | null { + if (typeof value === 'number' && Number.isFinite(value)) { + return value + } + if (typeof value === 'string' && value.trim()) { + const parsed = Date.parse(value) + if (!Number.isNaN(parsed)) return parsed + } + return null +} + +export function normalizeAuditEvent(raw: unknown): AuditEvent | null { + const source = asRecord(raw) + const id = asString(source.id) + if (!id) return null + + const metadataSource = asRecord(source.metadata) + + return { + id, + actor: asString(source.actor) || 'system', + action: asString(source.action) || 'unknown.action', + resource: asString(source.resource) || 'unknown_resource', + result: normalizeResult(asString(source.result)), + timestamp: normalizeTimestamp(source.timestamp), + metadata: metadataSource, + } +} + +function buildAuditQuery(filters: AuditFilters, limit = 100): string { + const query = new URLSearchParams() + if (filters.action.trim()) query.set('action', filters.action.trim()) + if (filters.resource.trim()) query.set('resource', filters.resource.trim()) + if (filters.actor.trim()) query.set('actor', filters.actor.trim()) + if (filters.result) query.set('result', filters.result) + if (filters.since.trim()) query.set('since', filters.since.trim()) + if (filters.until.trim()) query.set('until', filters.until.trim()) + query.set('limit', String(limit)) + return query.toString() +} + +export async function loadAuditEvents(filters: AuditFilters): Promise { + const query = buildAuditQuery(filters) + const rows = await apiRequest(`/api/admin/audit/?${query}`) + + return rows + .map(normalizeAuditEvent) + .filter((event): event is AuditEvent => event !== null) + .sort((a, b) => (b.timestamp ?? 0) - (a.timestamp ?? 0)) +} + +export function filterAuditEvents(events: AuditEvent[], query: string): AuditEvent[] { + const normalized = query.trim().toLowerCase() + if (!normalized) return events + + return events.filter(event => ( + event.action.toLowerCase().includes(normalized) + || event.actor.toLowerCase().includes(normalized) + || event.resource.toLowerCase().includes(normalized) + || event.result.toLowerCase().includes(normalized) + )) +} diff --git a/Frontend/src/app/admin-content.ts b/Frontend/src/app/admin-content.ts new file mode 100644 index 0000000..71e3bab --- /dev/null +++ b/Frontend/src/app/admin-content.ts @@ -0,0 +1,244 @@ +import { listResource } from './api/client' + +export type ContentPublishStatus = 'draft' | 'published' | 'scheduled' | 'archived' | 'unknown' + +export type ContentRecordType = 'page' | 'blog' | 'form' | 'asset' + +export type ContentSeoState = 'ready' | 'missing' | 'unknown' + +export type ContentRightsState = 'protected' | 'review' | 'unknown' + +export type AdminContentRecord = { + id: string + type: ContentRecordType + title: string + status: ContentPublishStatus + updatedAt: number | null + owner: string | null + to: string + seo: ContentSeoState + rights: ContentRightsState +} + +export type AdminContentData = { + records: AdminContentRecord[] + loadWarnings: string[] +} + +type ResourceLoader = (resource: string) => Promise + +type DateLike = string | number | null | undefined + +function asRecord(value: unknown): Record { + if (typeof value === 'object' && value !== null) return value as Record + return {} +} + +function asString(value: unknown): string { + return typeof value === 'string' ? value.trim() : '' +} + +function asBoolean(value: unknown): boolean | null { + if (typeof value === 'boolean') return value + return null +} + +function parseDate(value: DateLike): number | null { + if (typeof value === 'number' && Number.isFinite(value)) { + return value + } + if (typeof value === 'string' && value.trim() !== '') { + const parsed = Date.parse(value) + return Number.isNaN(parsed) ? null : parsed + } + return null +} + +function pickDate(source: Record): number | null { + return ( + parseDate(source.updatedAt as DateLike) + ?? parseDate(source.updated_at as DateLike) + ?? parseDate(source.publishedAt as DateLike) + ?? parseDate(source.createdAt as DateLike) + ?? parseDate(source.created_at as DateLike) + ) +} + +function inferStatus(source: Record): ContentPublishStatus { + const normalized = asString(source.status).toLowerCase() + if (normalized === 'draft' || normalized === 'published' || normalized === 'scheduled' || normalized === 'archived') { + return normalized + } + + const archived = asBoolean(source.archived) + if (archived === true) return 'archived' + + const published = asBoolean(source.published) + if (published === true) return 'published' + + const publishAt = parseDate(source.publishAt as DateLike) ?? parseDate(source.scheduledAt as DateLike) + if (publishAt !== null && publishAt > Date.now()) return 'scheduled' + + const publishedAt = parseDate(source.publishedAt as DateLike) + if (publishedAt !== null) return 'published' + + if (normalized) return 'unknown' + return 'draft' +} + +function inferOwner(source: Record): string | null { + const direct = [ + source.owner, + source.ownerName, + source.author, + source.authorName, + source.createdBy, + source.updatedBy, + ] + + for (const candidate of direct) { + const value = asString(candidate) + if (value) return value + } + + const createdBy = asRecord(source.createdBy) + for (const key of ['name', 'fullName', 'username', 'email']) { + const value = asString(createdBy[key]) + if (value) return value + } + + return null +} + +function inferTitle(source: Record, fallback: string): string { + for (const key of ['title', 'name', 'label', 'slug', 'id']) { + const value = asString(source[key]) + if (value) return value + } + return fallback +} + +function inferSeoState(source: Record, type: ContentRecordType): ContentSeoState { + if (type !== 'page' && type !== 'blog') { + return 'unknown' + } + + const seo = asRecord(source.seo) + const title = asString(seo.title) + const description = asString(seo.description) + const canonicalUrl = asString(seo.canonicalUrl) + + if (title || description || canonicalUrl) { + return 'ready' + } + + return 'missing' +} + +function inferRightsState(source: Record): ContentRightsState { + const rights = asRecord(source.copyright) + const protectedFlags = [ + source.protected, + source.isProtected, + source.encrypted, + rights.protected, + rights.enforced, + ] + + if (protectedFlags.some(flag => asBoolean(flag) === true)) { + return 'protected' + } + + const reviewSignals = [ + source.copyrightStatus, + rights.status, + rights.claimStatus, + source.license, + ] + + if (reviewSignals.some(signal => asString(signal) !== '')) { + return 'review' + } + + return 'unknown' +} + +function mapRecord(raw: unknown, type: ContentRecordType, to: string): AdminContentRecord | null { + const source = asRecord(raw) + const id = asString(source.id) + if (!id) return null + + return { + id, + type, + title: inferTitle(source, `${type}-${id}`), + status: inferStatus(source), + updatedAt: pickDate(source), + owner: inferOwner(source), + to, + seo: inferSeoState(source, type), + rights: inferRightsState(source), + } +} + +export function filterContentRecords( + records: AdminContentRecord[], + statusFilter: 'all' | Exclude, + query: string, +): AdminContentRecord[] { + const normalizedQuery = query.trim().toLowerCase() + + return records.filter(record => { + if (statusFilter !== 'all' && record.status !== statusFilter) { + return false + } + + if (!normalizedQuery) { + return true + } + + return ( + record.title.toLowerCase().includes(normalizedQuery) + || record.type.toLowerCase().includes(normalizedQuery) + || (record.owner ?? '').toLowerCase().includes(normalizedQuery) + ) + }) +} + +export async function loadAdminContentData(loader: ResourceLoader = listResource): Promise { + const warnings: string[] = [] + + const resources: Array<{ resource: string; type: ContentRecordType; to: string }> = [ + { resource: 'pages', type: 'page', to: '/admin/builder' }, + { resource: 'blog/posts', type: 'blog', to: '/admin/blog' }, + { resource: 'forms', type: 'form', to: '/admin/forms' }, + { resource: 'assets', type: 'asset', to: '/admin/assets' }, + ] + + const loaded = await Promise.all( + resources.map(async item => { + try { + const rows = await loader(item.resource) + return { ...item, rows } + } catch { + warnings.push(`Unable to load ${item.resource}`) + return { ...item, rows: [] as unknown[] } + } + }), + ) + + const records = loaded + .flatMap(item => item.rows.map(row => mapRecord(row, item.type, item.to))) + .filter((row): row is AdminContentRecord => row !== null) + .sort((a, b) => { + const aDate = a.updatedAt ?? 0 + const bDate = b.updatedAt ?? 0 + if (aDate !== bDate) return bDate - aDate + return a.title.localeCompare(b.title) + }) + + return { + records, + loadWarnings: warnings, + } +} diff --git a/Frontend/src/app/admin-dashboard.ts b/Frontend/src/app/admin-dashboard.ts new file mode 100644 index 0000000..2a522ec --- /dev/null +++ b/Frontend/src/app/admin-dashboard.ts @@ -0,0 +1,159 @@ +import { listResource } from './api/client' + +export type DashboardKey = + | 'pages' + | 'blogPosts' + | 'forms' + | 'submissions' + | 'learners' + | 'courses' + | 'notifications' + | 'assets' + +export type AdminDashboardCount = { + key: DashboardKey + label: string + value: number | null + to: string + placeholder?: string +} + +export type SetupWarning = { + key: 'sendgrid' | 'stripe' | 'asset-encryption' + title: string + message: string +} + +export type AdminDashboardData = { + counts: AdminDashboardCount[] + setupWarnings: SetupWarning[] + loadWarnings: string[] +} + +type ResourceLoader = (resource: string) => Promise + +type FormRecord = { id: string } +type UserRecord = { roles?: string[]; role?: string } + +async function safeListCount(loader: ResourceLoader, resource: string): Promise<{ count: number | null; warning?: string }> { + try { + const result = await loader(resource) + return { count: result.length } + } catch { + return { count: null, warning: `Unable to load ${resource}` } + } +} + +function normalizeRoleList(user: UserRecord): string[] { + if (Array.isArray(user.roles)) { + return user.roles.map(role => String(role).trim().toLowerCase()).filter(Boolean) + } + if (typeof user.role === 'string' && user.role.trim() !== '') { + return [user.role.trim().toLowerCase()] + } + return [] +} + +export async function loadAdminDashboardData(loader: ResourceLoader = listResource): Promise { + const warnings: string[] = [] + + const [pages, blogPosts, forms, users, scormCourses, liveCourses, notifications] = await Promise.all([ + safeListCount(loader, 'pages'), + safeListCount(loader, 'blog/posts'), + safeListCount(loader, 'forms'), + safeListCount(loader, 'users'), + safeListCount(loader, 'scorm'), + safeListCount(loader, 'live-courses'), + safeListCount(loader, 'notifications/deliveries'), + ]) + + for (const result of [pages, blogPosts, forms, users, scormCourses, liveCourses, notifications]) { + if (result.warning) warnings.push(result.warning) + } + + let learnersCount: number | null = null + try { + const records = await loader('users') + learnersCount = records.filter(user => normalizeRoleList(user).includes('learner')).length + } catch { + warnings.push('Unable to derive learner count from users') + } + + let submissionsCount: number | null = null + try { + const formRecords = await loader('forms') + const submissions = await Promise.all( + formRecords + .map(form => String(form.id ?? '').trim()) + .filter(Boolean) + .map(formID => safeListCount(loader, `forms/${encodeURIComponent(formID)}/submissions`)), + ) + + if (submissions.length === 0) { + submissionsCount = 0 + } else { + let sum = 0 + let hasAny = false + for (const result of submissions) { + if (result.count !== null) { + hasAny = true + sum += result.count + } + } + submissionsCount = hasAny ? sum : null + if (!hasAny) { + warnings.push('Unable to load form submissions') + } + } + } catch { + warnings.push('Unable to load forms for submissions summary') + } + + let coursesCount: number | null = null + if (scormCourses.count !== null || liveCourses.count !== null) { + coursesCount = (scormCourses.count ?? 0) + (liveCourses.count ?? 0) + } + + const counts: AdminDashboardCount[] = [ + { key: 'pages', label: 'Pages', value: pages.count, to: '/admin/content', placeholder: 'Pages data unavailable' }, + { key: 'blogPosts', label: 'Blog Posts', value: blogPosts.count, to: '/admin/blog', placeholder: 'Blog data unavailable' }, + { key: 'forms', label: 'Forms', value: forms.count, to: '/admin/forms', placeholder: 'Forms data unavailable' }, + { key: 'submissions', label: 'Submissions', value: submissionsCount, to: '/admin/forms', placeholder: 'Submission data unavailable' }, + { key: 'learners', label: 'Learners', value: learnersCount, to: '/admin/learners', placeholder: 'Learner data unavailable' }, + { key: 'courses', label: 'Courses', value: coursesCount, to: '/admin/scorm', placeholder: 'Course data unavailable' }, + { key: 'notifications', label: 'Notifications', value: notifications.count, to: '/admin/notifications', placeholder: 'Notification data unavailable' }, + { key: 'assets', label: 'Assets', value: null, to: '/admin/assets', placeholder: 'Asset index not available yet' }, + ] + + const setupWarnings: SetupWarning[] = [] + if ((notifications.count ?? 0) === 0) { + setupWarnings.push({ + key: 'sendgrid', + title: 'SendGrid setup may be incomplete', + message: 'No notification deliveries are available yet. Verify SendGrid provider and credentials.', + }) + } + + setupWarnings.push({ + key: 'stripe', + title: 'Stripe setup needs verification', + message: 'Billing health checks are not yet exposed in this dashboard. Confirm Stripe keys and webhook secret.', + }) + + setupWarnings.push({ + key: 'asset-encryption', + title: 'Asset encryption setup incomplete', + message: 'Asset encryption configuration status is not enabled yet. Treat uploads as not encrypted-at-rest by platform policy.', + }) + + const hasAnyLoadedMetric = counts.some(count => count.value !== null) + if (!hasAnyLoadedMetric) { + throw new Error('Unable to load dashboard metrics') + } + + return { + counts, + setupWarnings, + loadWarnings: warnings, + } +} diff --git a/Frontend/src/app/admin-learners.ts b/Frontend/src/app/admin-learners.ts new file mode 100644 index 0000000..4269ffd --- /dev/null +++ b/Frontend/src/app/admin-learners.ts @@ -0,0 +1,293 @@ +import { listResource } from './api/client' + +export type LearnerUserStatus = 'active' | 'inactive' | 'pending' | 'unknown' +export type LearnerRole = 'learner' | 'instructor' | 'editor' | 'admin' | 'unknown' + +export type LearnerEnrollmentStatus = 'not_started' | 'in_progress' | 'completed' | 'failed' | 'unknown' + +export type LearnerEnrollment = { + id: string + userId: string + courseId: string + courseTitle: string + courseType: 'cms' | 'scorm' | 'unknown' + status: LearnerEnrollmentStatus + progress: number | null + score: number | null + startedAt: number | null + completedAt: number | null + timeSpentMin: number | null +} + +export type LearnerProfile = { + id: string + displayName: string + email: string + status: LearnerUserStatus + roles: LearnerRole[] + createdAt: number | null + updatedAt: number | null + lastActiveAt: number | null + enrollments: LearnerEnrollment[] +} + +export type AdminLearnerData = { + learners: LearnerProfile[] + loadWarnings: string[] +} + +type ResourceLoader = (resource: string) => Promise + +type StatusFilter = 'all' | LearnerUserStatus +type RoleFilter = 'all' | LearnerRole + +function asRecord(value: unknown): Record { + if (typeof value === 'object' && value !== null) return value as Record + return {} +} + +function asString(value: unknown): string { + return typeof value === 'string' ? value.trim() : '' +} + +function asNumber(value: unknown): number | null { + if (typeof value === 'number' && Number.isFinite(value)) return value + if (typeof value === 'string' && value.trim() !== '') { + const parsed = Number(value) + return Number.isFinite(parsed) ? parsed : null + } + return null +} + +function asStringList(value: unknown): string[] { + if (!Array.isArray(value)) return [] + return value.map(asString).filter(Boolean) +} + +function normalizeRole(value: string): LearnerRole { + const normalized = value.toLowerCase() + if (normalized === 'learner') return 'learner' + if (normalized === 'instructor') return 'instructor' + if (normalized === 'editor') return 'editor' + if (normalized === 'admin') return 'admin' + return 'unknown' +} + +function normalizeRoleList(user: Record): LearnerRole[] { + const collected = [ + ...asStringList(user.roles), + ...asStringList(user.realmRoles), + asString(user.role), + ].map(normalizeRole) + + const unique = Array.from(new Set(collected.filter(Boolean))) + return unique.length > 0 ? unique : ['unknown'] +} + +function normalizeStatus(value: string): LearnerUserStatus { + const normalized = value.toLowerCase() + if (normalized === 'active') return 'active' + if (normalized === 'inactive') return 'inactive' + if (normalized === 'pending') return 'pending' + return 'unknown' +} + +function normalizeEnrollmentStatus(value: string): LearnerEnrollmentStatus { + const normalized = value.toLowerCase() + if (normalized === 'not_started') return 'not_started' + if (normalized === 'in_progress') return 'in_progress' + if (normalized === 'completed') return 'completed' + if (normalized === 'failed') return 'failed' + return 'unknown' +} + +function pickDate(source: Record, ...keys: string[]): number | null { + for (const key of keys) { + const value = asNumber(source[key]) + if (value !== null) return value + } + return null +} + +function normalizeLearnerProfile(raw: unknown): LearnerProfile | null { + const source = asRecord(raw) + const id = asString(source.id) + if (!id) return null + + const displayName = asString(source.name) || asString(source.username) || asString(source.email) || `user-${id}` + const email = asString(source.email) + + return { + id, + displayName, + email, + status: normalizeStatus(asString(source.status) || 'unknown'), + roles: normalizeRoleList(source), + createdAt: pickDate(source, 'createdAt', 'created_at'), + updatedAt: pickDate(source, 'updatedAt', 'updated_at'), + lastActiveAt: pickDate(source, 'lastActive', 'lastActiveAt', 'updatedAt', 'updated_at'), + enrollments: [], + } +} + +function normalizeCourseType(raw: string): LearnerEnrollment['courseType'] { + const normalized = raw.toLowerCase() + if (normalized === 'cms') return 'cms' + if (normalized === 'scorm') return 'scorm' + return 'unknown' +} + +function clampProgress(progress: number | null): number | null { + if (progress === null) return null + if (progress < 0) return 0 + if (progress > 100) return 100 + return progress +} + +function normalizeEnrollment(raw: unknown, titleLookup: Map): LearnerEnrollment | null { + const source = asRecord(raw) + const id = asString(source.id) + const userId = asString(source.userId) + const courseId = asString(source.courseId) + + if (!id || !userId || !courseId) return null + + const title = asString(source.courseTitle) || titleLookup.get(courseId) || `Course ${courseId}` + + return { + id, + userId, + courseId, + courseTitle: title, + courseType: normalizeCourseType(asString(source.courseType)), + status: normalizeEnrollmentStatus(asString(source.status) || 'unknown'), + progress: clampProgress(asNumber(source.progress)), + score: asNumber(source.score), + startedAt: pickDate(source, 'startedAt', 'started_at'), + completedAt: pickDate(source, 'completedAt', 'completed_at'), + timeSpentMin: asNumber(source.timeSpentMin), + } +} + +function sortLearners(learners: LearnerProfile[]): LearnerProfile[] { + return [...learners].sort((a, b) => { + const aRecent = a.lastActiveAt ?? 0 + const bRecent = b.lastActiveAt ?? 0 + if (aRecent !== bRecent) return bRecent - aRecent + return a.displayName.localeCompare(b.displayName) + }) +} + +function buildCourseTitleLookup(resources: Array<{ id: string; title: string }>): Map { + const map = new Map() + for (const item of resources) { + if (item.id && item.title && !map.has(item.id)) { + map.set(item.id, item.title) + } + } + return map +} + +async function loadOptionalMap( + loader: ResourceLoader, + resource: string, + idKey: string, + titleKey: string, + warnings: string[], +): Promise> { + try { + const rows = await loader(resource) + return rows + .map(raw => { + const item = asRecord(raw) + return { + id: asString(item[idKey]), + title: asString(item[titleKey]), + } + }) + .filter(item => item.id && item.title) + } catch { + warnings.push(`Unable to load ${resource}`) + return [] + } +} + +export async function loadAdminLearnerData(loader: ResourceLoader = listResource): Promise { + const warnings: string[] = [] + + const [users, enrollments, pages, scorm] = await Promise.all([ + loader('users'), + loader('enrollments').catch(() => { + warnings.push('Unable to load enrollments') + return [] as unknown[] + }), + loadOptionalMap(loader, 'pages', 'id', 'name', warnings), + loadOptionalMap(loader, 'scorm', 'id', 'title', warnings), + ]) + + const learnerProfiles = users + .map(normalizeLearnerProfile) + .filter((user): user is LearnerProfile => user !== null) + .filter(user => user.roles.includes('learner')) + + const byId = new Map(learnerProfiles.map(user => [user.id, user])) + const titleLookup = buildCourseTitleLookup([...pages, ...scorm]) + + for (const raw of enrollments) { + const enrollment = normalizeEnrollment(raw, titleLookup) + if (!enrollment) continue + + const user = byId.get(enrollment.userId) + if (!user) continue + user.enrollments.push(enrollment) + } + + const learners = sortLearners(learnerProfiles).map(user => ({ + ...user, + enrollments: [...user.enrollments].sort((a, b) => { + const aUpdated = a.completedAt ?? a.startedAt ?? 0 + const bUpdated = b.completedAt ?? b.startedAt ?? 0 + if (aUpdated !== bUpdated) return bUpdated - aUpdated + return a.courseTitle.localeCompare(b.courseTitle) + }), + })) + + return { + learners, + loadWarnings: warnings, + } +} + +export function filterLearners( + learners: LearnerProfile[], + query: string, + statusFilter: StatusFilter, + roleFilter: RoleFilter, +): LearnerProfile[] { + const normalizedQuery = query.trim().toLowerCase() + + return learners.filter(learner => { + if (statusFilter !== 'all' && learner.status !== statusFilter) { + return false + } + + if (roleFilter !== 'all' && !learner.roles.includes(roleFilter)) { + return false + } + + if (!normalizedQuery) return true + + return ( + learner.displayName.toLowerCase().includes(normalizedQuery) + || learner.email.toLowerCase().includes(normalizedQuery) + || learner.roles.some(role => role.includes(normalizedQuery)) + ) + }) +} + +export function progressSummary(enrollment: LearnerEnrollment): { label: string; value: number | null } { + if (enrollment.progress === null) { + return { label: 'Not yet tracked', value: null } + } + return { label: `${enrollment.progress}%`, value: enrollment.progress } +} diff --git a/Frontend/src/app/admin-nav.ts b/Frontend/src/app/admin-nav.ts new file mode 100644 index 0000000..be057f7 --- /dev/null +++ b/Frontend/src/app/admin-nav.ts @@ -0,0 +1,67 @@ +export type AdminNavItem = { + key: string + label: string + to: string + disabled?: boolean + description?: string +} + +const BASE_ADMIN_NAV: AdminNavItem[] = [ + { key: 'workspace', label: 'Workspace', to: '/home' }, + { key: 'dashboard', label: 'Dashboard', to: '/admin' }, + { key: 'builder', label: 'Builder', to: '/admin/builder' }, + { key: 'content', label: 'Content', to: '/admin/content' }, + { key: 'courses', label: 'Courses', to: '/admin/scorm' }, + { key: 'blog', label: 'Blog', to: '/admin/blog' }, + { key: 'forms', label: 'Forms', to: '/admin/forms' }, + { key: 'sitemap', label: 'Sitemap', to: '/admin/sitemap' }, + { key: 'themes', label: 'Themes', to: '/admin/keycloak-themes' }, + { key: 'learners', label: 'Learners', to: '/admin/learners' }, + { key: 'notifications', label: 'Notifications', to: '/admin/notifications' }, + { key: 'assets', label: 'Assets', to: '/admin/assets' }, + { key: 'billing', label: 'Billing', to: '/admin/billing' }, + { key: 'communities', label: 'Communities', to: '/admin/communities' }, + { key: 'audit', label: 'Audit', to: '/admin/audit' }, + { key: 'settings', label: 'Settings', to: '/admin/settings' }, +] + +const ADMIN_ONLY_KEYS = new Set(['sitemap', 'themes', 'billing', 'audit', 'settings']) +const INSTRUCTOR_DISABLED_KEYS = new Set(['notifications', 'assets', 'billing', 'audit', 'settings']) + +export function hasAdminShellAccess(roles: string[]): boolean { + return roles.includes('admin') || roles.includes('instructor') +} + +export function isAdminOnlyAdminRoute(pathname: string): boolean { + return pathname.startsWith('/admin/sitemap') + || pathname.startsWith('/admin/keycloak-themes') + || pathname.startsWith('/admin/billing') + || pathname.startsWith('/admin/audit') + || pathname.startsWith('/admin/settings') +} + +export function getAdminNavItems(roles: string[]): AdminNavItem[] { + const isAdmin = roles.includes('admin') + const isInstructor = roles.includes('instructor') + + if (!isAdmin && !isInstructor) { + return [] + } + + if (isAdmin) { + return BASE_ADMIN_NAV + } + + return BASE_ADMIN_NAV + .filter(item => !ADMIN_ONLY_KEYS.has(item.key)) + .map(item => { + if (!INSTRUCTOR_DISABLED_KEYS.has(item.key)) { + return item + } + return { + ...item, + disabled: true, + description: 'Requires additional permissions', + } + }) +} diff --git a/Frontend/src/app/admin-settings.ts b/Frontend/src/app/admin-settings.ts new file mode 100644 index 0000000..49cd5ea --- /dev/null +++ b/Frontend/src/app/admin-settings.ts @@ -0,0 +1,156 @@ +import { apiRequest } from './api/client' + +export type IntegrationKey = 'sendgrid' | 'stripe' | 'minio' | 'keycloak' | 'assetEncryption' + +export type IntegrationStatus = { + configured: boolean + setupInstruction: string + details: string[] +} + +export type AdminEditableSettings = { + platformIdentity: { + siteName: string + supportEmail: string + } + seoDefaults: { + defaultTitle: string + defaultDescription: string + canonicalBaseUrl: string + } + consentLinks: { + privacyPolicyUrl: string + termsOfServiceUrl: string + cookiePolicyUrl: string + } +} + +export type AdminSettingsData = { + integrations: Record + settings: AdminEditableSettings + environment: { + notificationProvider: string + keycloakAuthDisabled: boolean + keycloakThemeDeploy: boolean + databaseConnected: boolean + } +} + +type RawIntegration = { + configured?: unknown + setupInstruction?: unknown + [key: string]: unknown +} + +type RawSettingsResponse = { + integrations?: Record + settings?: Record + environment?: Record +} + +function asRecord(value: unknown): Record { + if (typeof value === 'object' && value !== null) return value as Record + return {} +} + +function asString(value: unknown): string { + return typeof value === 'string' ? value.trim() : '' +} + +function asBool(value: unknown): boolean { + return value === true +} + +function normalizeIntegration(raw: RawIntegration): IntegrationStatus { + const details: string[] = [] + for (const [key, value] of Object.entries(raw)) { + if (key === 'configured' || key === 'setupInstruction') continue + if (typeof value === 'string' && value.trim()) { + details.push(`${key}: ${value.trim()}`) + continue + } + if (typeof value === 'number') { + details.push(`${key}: ${value}`) + continue + } + if (typeof value === 'boolean') { + details.push(`${key}: ${value ? 'yes' : 'no'}`) + } + } + + return { + configured: asBool(raw.configured), + setupInstruction: asString(raw.setupInstruction) || 'See deployment configuration documentation for setup requirements.', + details, + } +} + +export function normalizeAdminSettingsResponse(raw: unknown): AdminSettingsData { + const source = asRecord(raw) as RawSettingsResponse + const integrations = source.integrations ?? {} + + const defaultSettings: AdminEditableSettings = { + platformIdentity: { + siteName: '', + supportEmail: '', + }, + seoDefaults: { + defaultTitle: '', + defaultDescription: '', + canonicalBaseUrl: '', + }, + consentLinks: { + privacyPolicyUrl: '', + termsOfServiceUrl: '', + cookiePolicyUrl: '', + }, + } + + const rawSettings = asRecord(source.settings) + const rawIdentity = asRecord(rawSettings.platformIdentity) + const rawSeo = asRecord(rawSettings.seoDefaults) + const rawConsent = asRecord(rawSettings.consentLinks) + + defaultSettings.platformIdentity.siteName = asString(rawIdentity.siteName) + defaultSettings.platformIdentity.supportEmail = asString(rawIdentity.supportEmail) + defaultSettings.seoDefaults.defaultTitle = asString(rawSeo.defaultTitle) + defaultSettings.seoDefaults.defaultDescription = asString(rawSeo.defaultDescription) + defaultSettings.seoDefaults.canonicalBaseUrl = asString(rawSeo.canonicalBaseUrl) + defaultSettings.consentLinks.privacyPolicyUrl = asString(rawConsent.privacyPolicyUrl) + defaultSettings.consentLinks.termsOfServiceUrl = asString(rawConsent.termsOfServiceUrl) + defaultSettings.consentLinks.cookiePolicyUrl = asString(rawConsent.cookiePolicyUrl) + + const rawEnvironment = asRecord(source.environment) + + return { + integrations: { + sendgrid: normalizeIntegration(integrations.sendgrid ?? {}), + stripe: normalizeIntegration(integrations.stripe ?? {}), + minio: normalizeIntegration(integrations.minio ?? {}), + keycloak: normalizeIntegration(integrations.keycloak ?? {}), + assetEncryption: normalizeIntegration(integrations.assetEncryption ?? {}), + }, + settings: defaultSettings, + environment: { + notificationProvider: asString(rawEnvironment.notificationProvider), + keycloakAuthDisabled: asBool(rawEnvironment.keycloakAuthDisabled), + keycloakThemeDeploy: asBool(rawEnvironment.keycloakThemeDeploy), + databaseConnected: asBool(rawEnvironment.databaseConnected), + }, + } +} + +export async function loadAdminSettings(): Promise { + const result = await apiRequest('/api/admin/settings/') + return normalizeAdminSettingsResponse(result) +} + +export async function saveAdminSettings(settings: AdminEditableSettings): Promise { + const result = await apiRequest<{ settings?: unknown }>('/api/admin/settings/', { + method: 'PATCH', + body: JSON.stringify(settings), + }) + + const normalized = normalizeAdminSettingsResponse({ settings: result.settings }) + return normalized.settings +} diff --git a/Frontend/src/app/api/client.ts b/Frontend/src/app/api/client.ts new file mode 100644 index 0000000..1e4b11b --- /dev/null +++ b/Frontend/src/app/api/client.ts @@ -0,0 +1,82 @@ +import { getAccessToken } from '../auth/keycloak' + +const BASE = import.meta.env.VITE_API_BASE ?? '' + +export class ApiError extends Error { + status: number + + constructor(message: string, status: number) { + super(message) + this.name = 'ApiError' + this.status = status + } +} + +type RequestOptions = RequestInit & { + skipAuth?: boolean +} + +export async function apiRequest(path: string, options: RequestOptions = {}): Promise { + const headers = new Headers(options.headers ?? {}) + headers.set('Content-Type', 'application/json') + + if (!options.skipAuth) { + const token = getAccessToken() + if (token) headers.set('Authorization', `Bearer ${token}`) + } + + const res = await fetch(`${BASE}${path}`, { ...options, headers }) + + if (!res.ok) { + let message = `Request failed with status ${res.status}` + try { + const body = await res.json() as { error?: string } + if (body?.error) message = body.error + } catch { + // Non-JSON errors use the status fallback. + } + throw new ApiError(message, res.status) + } + + if (res.status === 204) return null as T + return await res.json() as T +} + +export async function listResource(resource: string): Promise { + const result = await apiRequest(`/api/${resource}`) + return Array.isArray(result) ? result : [] +} + +export async function getResource(resource: string, id: string): Promise { + return await apiRequest(`/api/${resource}/${id}`) +} + +export async function createResource(resource: string, payload: Record): Promise { + return await apiRequest(`/api/${resource}`, { method: 'POST', body: JSON.stringify(payload) }) +} + +export async function patchResource(resource: string, id: string, payload: Record): Promise { + return await apiRequest(`/api/${resource}/${id}`, { method: 'PATCH', body: JSON.stringify(payload) }) +} + +export async function deleteResource(resource: string, id: string): Promise { + await apiRequest(`/api/${resource}/${id}`, { method: 'DELETE' }) +} + +export async function syncCollection(resource: string, desired: T[]): Promise { + const existing = await listResource(resource) + const existingByID = new Map(existing.map(item => [item.id, item])) + + for (const item of desired) { + if (existingByID.has(item.id)) { + await patchResource(resource, item.id, item as unknown as Record) + existingByID.delete(item.id) + } else { + await createResource(resource, item as unknown as Record) + } + } + + for (const stale of existingByID.values()) { + await deleteResource(resource, stale.id) + } +} diff --git a/Frontend/src/app/auth/keycloak.ts b/Frontend/src/app/auth/keycloak.ts new file mode 100644 index 0000000..a5c8001 --- /dev/null +++ b/Frontend/src/app/auth/keycloak.ts @@ -0,0 +1,59 @@ +import Keycloak from 'keycloak-js' + +const isE2EMockMode = import.meta.env.VITE_E2E_AUTH_MODE === 'mock' + +const keycloak = new Keycloak({ + url: import.meta.env.VITE_KEYCLOAK_URL ?? 'http://localhost:8081', + realm: import.meta.env.VITE_KEYCLOAK_REALM ?? 'cms', + clientId: import.meta.env.VITE_KEYCLOAK_CLIENT_ID ?? 'cms-frontend', +}) + +let refreshTimer: number | null = null + +export async function initKeycloak(): Promise { + if (isE2EMockMode) return + + await keycloak.init({ + onLoad: 'login-required', + checkLoginIframe: false, + pkceMethod: 'S256', + }) + + if (refreshTimer !== null) window.clearInterval(refreshTimer) + + refreshTimer = window.setInterval(async () => { + try { + await keycloak.updateToken(30) + } catch { + await keycloak.login() + } + }, 20_000) +} + +export function getAccessToken(): string | null { + if (isE2EMockMode) return 'e2e-mock-token' + return keycloak.token ?? null +} + +export function logout(): Promise { + if (isE2EMockMode) return Promise.resolve() + return keycloak.logout({ redirectUri: window.location.origin }) +} + +export function currentUser() { + if (isE2EMockMode) { + return { + username: 'e2e-learner', + email: 'e2e-learner@example.com', + roles: ['learner', 'admin'], + } + } + + return { + username: keycloak.tokenParsed?.preferred_username, + email: keycloak.tokenParsed?.email, + roles: (keycloak.tokenParsed?.realm_access as { roles?: string[] } | undefined)?.roles ?? [], + } +} + +export default keycloak diff --git a/Frontend/src/app/blog-seo.ts b/Frontend/src/app/blog-seo.ts new file mode 100644 index 0000000..349b491 --- /dev/null +++ b/Frontend/src/app/blog-seo.ts @@ -0,0 +1,47 @@ +type BlogSeoInput = { + title: string + description?: string + canonicalUrl?: string +} + +function upsertMeta(name: string, content: string, attr: 'name' | 'property' = 'name') { + const selector = `meta[${attr}="${name}"]` + let element = document.head.querySelector(selector) as HTMLMetaElement | null + if (!element) { + element = document.createElement('meta') + element.setAttribute(attr, name) + document.head.appendChild(element) + } + element.setAttribute('content', content) +} + +function upsertCanonical(url?: string) { + const existing = document.head.querySelector('link[rel="canonical"]') as HTMLLinkElement | null + if (!url) { + existing?.remove() + return + } + + if (existing) { + existing.href = url + return + } + + const link = document.createElement('link') + link.rel = 'canonical' + link.href = url + document.head.appendChild(link) +} + +export function applyBlogSeo(input: BlogSeoInput): void { + document.title = input.title + + if (input.description) { + upsertMeta('description', input.description) + upsertMeta('og:description', input.description, 'property') + } + + upsertMeta('og:title', input.title, 'property') + upsertMeta('og:type', 'article', 'property') + upsertCanonical(input.canonicalUrl) +} diff --git a/Frontend/src/app/blog-utils.ts b/Frontend/src/app/blog-utils.ts new file mode 100644 index 0000000..c4c0111 --- /dev/null +++ b/Frontend/src/app/blog-utils.ts @@ -0,0 +1,40 @@ +import type { BlogPost, BlogTaxonomy } from './blog' + +export function normalizeToken(value: string): string { + return value + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') +} + +export function taxonomyToken(item: BlogTaxonomy): string { + return normalizeToken(item.slug || item.name || item.id) +} + +export function resolveTaxonomyLabel(token: string, items: BlogTaxonomy[]): string { + const normalized = normalizeToken(token) + const matched = items.find(item => taxonomyToken(item) === normalized) + return matched?.name ?? token +} + +export function postMatchesFilters(post: BlogPost, categoryToken?: string, tagToken?: string): boolean { + const categories = (post.categories ?? []).map(normalizeToken) + const tags = (post.tags ?? []).map(normalizeToken) + + if (categoryToken && !categories.includes(normalizeToken(categoryToken))) { + return false + } + if (tagToken && !tags.includes(normalizeToken(tagToken))) { + return false + } + return true +} + +export function summarizePost(post: BlogPost): string { + if (post.excerpt && post.excerpt.trim()) return post.excerpt.trim() + if (post.content && post.content.trim()) { + return post.content.trim().replace(/\s+/g, ' ').slice(0, 200) + } + return '' +} diff --git a/Frontend/src/app/blog.ts b/Frontend/src/app/blog.ts new file mode 100644 index 0000000..1165deb --- /dev/null +++ b/Frontend/src/app/blog.ts @@ -0,0 +1,168 @@ +import { apiRequest } from './api/client' + +export type BlogStatus = 'draft' | 'scheduled' | 'published' + +export type BlogPost = { + id: string + title: string + slug: string + excerpt?: string + content?: string + authorId?: string + contentPagePath?: string + categories?: string[] + tags?: string[] + status?: BlogStatus + publishedAt?: number + updatedAt?: number + createdAt?: number + seo?: { + title?: string + description?: string + canonicalUrl?: string + } +} + +export type BlogTaxonomy = { + id: string + name: string + slug?: string +} + +function asString(value: unknown): string { + return typeof value === 'string' ? value : '' +} + +function asStringArray(value: unknown): string[] { + if (!Array.isArray(value)) return [] + return value.filter((item): item is string => typeof item === 'string').map(item => item.trim()).filter(Boolean) +} + +function asNumber(value: unknown): number | undefined { + if (typeof value === 'number' && Number.isFinite(value)) return value + return undefined +} + +function normalizePost(raw: unknown): BlogPost { + const source = (raw ?? {}) as Record + return { + id: asString(source.id), + title: asString(source.title), + slug: asString(source.slug), + excerpt: asString(source.excerpt) || undefined, + content: asString(source.content) || undefined, + authorId: asString(source.authorId) || undefined, + contentPagePath: asString(source.contentPagePath || source.content_page_path || source.linkedPagePath || source.contentPageUrl) || undefined, + categories: asStringArray(source.categories), + tags: asStringArray(source.tags), + status: asString(source.status) as BlogStatus, + publishedAt: asNumber(source.publishedAt), + updatedAt: asNumber(source.updatedAt), + createdAt: asNumber(source.createdAt), + seo: typeof source.seo === 'object' && source.seo !== null + ? { + title: asString((source.seo as Record).title) || undefined, + description: asString((source.seo as Record).description) || undefined, + canonicalUrl: asString((source.seo as Record).canonicalUrl) || undefined, + } + : undefined, + } +} + +function normalizeTaxonomy(raw: unknown): BlogTaxonomy { + const source = (raw ?? {}) as Record + return { + id: asString(source.id), + name: asString(source.name), + slug: asString(source.slug) || undefined, + } +} + +export async function fetchPublicBlogPosts(): Promise { + const result = await apiRequest('/api/public/blog/posts', { skipAuth: true }) + return Array.isArray(result) + ? result.map(normalizePost).filter(post => post.slug && post.title) + : [] +} + +export async function fetchPublicBlogPostBySlug(slug: string): Promise { + const result = await apiRequest(`/api/public/blog/posts/${encodeURIComponent(slug)}`, { skipAuth: true }) + return normalizePost(result) +} + +export async function fetchPublicBlogCategories(): Promise { + const result = await apiRequest('/api/public/blog/categories', { skipAuth: true }) + return Array.isArray(result) + ? result.map(normalizeTaxonomy).filter(item => item.name) + : [] +} + +export async function fetchPublicBlogTags(): Promise { + const result = await apiRequest('/api/public/blog/tags', { skipAuth: true }) + return Array.isArray(result) + ? result.map(normalizeTaxonomy).filter(item => item.name) + : [] +} + +export type AdminBlogPostInput = { + title: string + slug?: string + excerpt?: string + content?: string + contentPagePath?: string + categories?: string[] + tags?: string[] + authorId?: string + status?: BlogStatus + publishedAt?: number +} + +export async function fetchAdminBlogPosts(): Promise { + const result = await apiRequest('/api/blog/posts') + return Array.isArray(result) + ? result.map(normalizePost).filter(post => post.id && post.title) + : [] +} + +export async function fetchAdminBlogCategories(): Promise { + const result = await apiRequest('/api/blog/categories') + return Array.isArray(result) + ? result.map(normalizeTaxonomy).filter(item => item.name) + : [] +} + +export async function fetchAdminBlogTags(): Promise { + const result = await apiRequest('/api/blog/tags') + return Array.isArray(result) + ? result.map(normalizeTaxonomy).filter(item => item.name) + : [] +} + +export async function fetchAdminBlogAuthors(): Promise { + const result = await apiRequest('/api/blog/authors') + return Array.isArray(result) + ? result.map(normalizeTaxonomy).filter(item => item.name) + : [] +} + +export async function createAdminBlogPost(input: AdminBlogPostInput): Promise { + const result = await apiRequest('/api/blog/posts', { + method: 'POST', + body: JSON.stringify(input), + }) + return normalizePost(result) +} + +export async function updateAdminBlogPost(id: string, input: Partial): Promise { + const result = await apiRequest(`/api/blog/posts/${encodeURIComponent(id)}`, { + method: 'PATCH', + body: JSON.stringify(input), + }) + return normalizePost(result) +} + +export async function deleteAdminBlogPost(id: string): Promise { + await apiRequest(`/api/blog/posts/${encodeURIComponent(id)}`, { + method: 'DELETE', + }) +} diff --git a/Frontend/src/app/cms-utils.ts b/Frontend/src/app/cms-utils.ts new file mode 100644 index 0000000..e96b3cf --- /dev/null +++ b/Frontend/src/app/cms-utils.ts @@ -0,0 +1,158 @@ +import type { CanvasBlock, Page, DataSource, ThemeConfig } from './types' +import { createResource, deleteResource, listResource, patchResource, syncCollection } from './api/client' + +export async function loadPages(): Promise { + return await listResource('pages') +} + +export async function savePages(pages: Page[]): Promise { + await syncCollection('pages', pages) +} + +export async function savePage(page: Page, exists: boolean, expectedUpdatedAt?: number): Promise { + const payload = { + ...page, + ...(exists && expectedUpdatedAt !== undefined ? { expectedUpdatedAt } : {}), + } as unknown as Record + return exists + ? await patchResource('pages', page.id, payload) + : await createResource('pages', payload) +} + +export async function deletePage(pageId: string): Promise { + await deleteResource('pages', pageId) +} + +export async function loadDataSources(): Promise { + return await listResource('datasources') +} + +export async function saveDataSources(sources: DataSource[]): Promise { + await syncCollection('datasources', sources) +} + +// ── Tree helpers ─────────────────────────────────────────────── +export function findBlockInTree(blocks: CanvasBlock[], instanceId: string): CanvasBlock | null { + for (const b of blocks) { + if (b.instanceId === instanceId) return b + if (b.slots) { + for (const slot of b.slots) { + const found = findBlockInTree(slot, instanceId) + if (found) return found + } + } + } + return null +} + +export function updateBlockInTree( + blocks: CanvasBlock[], instanceId: string, updater: (b: CanvasBlock) => CanvasBlock, +): CanvasBlock[] { + return blocks.map(b => { + if (b.instanceId === instanceId) return updater(b) + if (b.slots) return { ...b, slots: b.slots.map(slot => updateBlockInTree(slot, instanceId, updater)) } + return b + }) +} + +export function removeBlockFromTree(blocks: CanvasBlock[], instanceId: string): CanvasBlock[] { + return blocks + .filter(b => b.instanceId !== instanceId) + .map(b => b.slots ? { ...b, slots: b.slots.map(slot => removeBlockFromTree(slot, instanceId)) } : b) +} + +export function moveBlockInTree(blocks: CanvasBlock[], instanceId: string, dir: 'up' | 'down'): CanvasBlock[] { + const idx = blocks.findIndex(b => b.instanceId === instanceId) + if (idx !== -1) { + if (dir === 'up' && idx === 0) return blocks + if (dir === 'down' && idx === blocks.length - 1) return blocks + const next = [...blocks] + const swap = dir === 'up' ? idx - 1 : idx + 1 + ;[next[idx], next[swap]] = [next[swap], next[idx]] + return next + } + return blocks.map(b => b.slots ? { ...b, slots: b.slots.map(slot => moveBlockInTree(slot, instanceId, dir)) } : b) +} + +export async function fetchDataSource(source: DataSource): Promise<{ data: Record[]; error: string | null }> { + if (source.type === 'static') { + try { + const parsed = JSON.parse(source.staticData || '[]') + return { data: Array.isArray(parsed) ? parsed : [parsed], error: null } + } catch { + return { data: [], error: 'Invalid JSON' } + } + } + try { + const hdrs: Record = { 'Content-Type': 'application/json' } + try { Object.assign(hdrs, JSON.parse(source.headers || '{}')) } catch {} + const res = await fetch(source.url, { + method: source.method, + headers: hdrs, + body: source.method === 'POST' && source.body ? source.body : undefined, + }) + if (!res.ok) return { data: [], error: `HTTP ${res.status} ${res.statusText}` } + const json = await res.json() + const resolved = source.path + ? source.path.split('.').reduce((acc: unknown, k) => acc && typeof acc === 'object' ? (acc as Record)[k] : undefined, json) + : json + const arr = Array.isArray(resolved) ? resolved : (resolved ? [resolved] : []) + return { data: arr as Record[], error: null } + } catch (e) { + return { data: [], error: String(e) } + } +} + +// ── Theme presets ────────────────────────────────────────────── +export const PRESET_THEMES: Record = { + zinc: { + name: 'Zinc', background: '#ffffff', foreground: '#09090b', primary: '#4f46e5', + primaryForeground: '#fafafa', muted: '#f4f4f5', mutedForeground: '#71717a', + border: '#e4e4e7', accent: '#f4f4f5', radius: 0.5, + fontFamily: 'Inter, ui-sans-serif, system-ui, sans-serif', + }, + dark: { + name: 'Dark', background: '#09090b', foreground: '#fafafa', primary: '#e4e4e7', + primaryForeground: '#18181b', muted: '#27272a', mutedForeground: '#a1a1aa', + border: '#3f3f46', accent: '#27272a', radius: 0.5, + fontFamily: 'Inter, ui-sans-serif, system-ui, sans-serif', + }, + rose: { + name: 'Rose', background: '#ffffff', foreground: '#0c0a09', primary: '#e11d48', + primaryForeground: '#fff1f2', muted: '#f5f5f4', mutedForeground: '#78716c', + border: '#e7e5e4', accent: '#fce7f3', radius: 0.5, + fontFamily: 'Inter, ui-sans-serif, system-ui, sans-serif', + }, + blue: { + name: 'Blue', background: '#ffffff', foreground: '#020817', primary: '#2563eb', + primaryForeground: '#eff6ff', muted: '#f1f5f9', mutedForeground: '#64748b', + border: '#e2e8f0', accent: '#dbeafe', radius: 0.375, + fontFamily: 'Inter, ui-sans-serif, system-ui, sans-serif', + }, + green: { + name: 'Green', background: '#ffffff', foreground: '#052e16', primary: '#16a34a', + primaryForeground: '#f0fdf4', muted: '#f0fdf4', mutedForeground: '#4d7c5f', + border: '#bbf7d0', accent: '#dcfce7', radius: 0.5, + fontFamily: 'Inter, ui-sans-serif, system-ui, sans-serif', + }, + orange: { + name: 'Orange', background: '#ffffff', foreground: '#1c0a00', primary: '#ea580c', + primaryForeground: '#fff7ed', muted: '#fff7ed', mutedForeground: '#9a3412', + border: '#fed7aa', accent: '#ffedd5', radius: 0.5, + fontFamily: 'Inter, ui-sans-serif, system-ui, sans-serif', + }, + purple: { + name: 'Purple', background: '#ffffff', foreground: '#0f0a19', primary: '#7c3aed', + primaryForeground: '#f5f3ff', muted: '#f5f3ff', mutedForeground: '#6d28d9', + border: '#ddd6fe', accent: '#ede9fe', radius: 0.5, + fontFamily: 'Inter, ui-sans-serif, system-ui, sans-serif', + }, + rounded: { + name: 'Soft', background: '#fafafa', foreground: '#1e1e2e', primary: '#6d28d9', + primaryForeground: '#ffffff', muted: '#f1f0f8', mutedForeground: '#6b7280', + border: '#e5e7eb', accent: '#ede9fe', radius: 1.25, + fontFamily: '"Plus Jakarta Sans", ui-sans-serif, system-ui, sans-serif', + }, +} + +export const DEFAULT_THEME = PRESET_THEMES.zinc diff --git a/Frontend/src/app/components/AdminPageSurface.tsx b/Frontend/src/app/components/AdminPageSurface.tsx new file mode 100644 index 0000000..b96fc47 --- /dev/null +++ b/Frontend/src/app/components/AdminPageSurface.tsx @@ -0,0 +1,19 @@ +import type { PropsWithChildren } from 'react' + +export type AdminPageSurfaceMode = 'framed' | 'plain' + +type AdminPageSurfaceProps = PropsWithChildren<{ + mode?: AdminPageSurfaceMode +}> + +export function AdminPageSurface({ children, mode = 'framed' }: AdminPageSurfaceProps) { + if (mode === 'plain') { + return
{children}
+ } + + return ( +
+ {children} +
+ ) +} \ No newline at end of file diff --git a/Frontend/src/app/components/BlockEditor.tsx b/Frontend/src/app/components/BlockEditor.tsx new file mode 100644 index 0000000..a5d8db7 --- /dev/null +++ b/Frontend/src/app/components/BlockEditor.tsx @@ -0,0 +1,246 @@ +import { useState } from 'react' +import { X, Type, Paintbrush, Palette, RotateCcw } from 'lucide-react' +import { BLOCKS } from './blocks' +import { ThemeBuilder } from './ThemeBuilder' +import type { ThemeConfig, CanvasBlock, CustomBlockDef, DataSource } from '../App' + +type Tab = 'content' | 'style' | 'theme' + +function ColorControl({ label, value, onChange, themeVar, themeLabel }: { + label: string + value: string + onChange: (v: string) => void + themeVar?: string + themeLabel?: string +}) { + const isTheme = value === themeVar + const hexVal = /^#[0-9a-fA-F]{6}$/.test(value) ? value : '#888888' + + return ( +
+
+ {label} + {themeVar && ( + + )} +
+ {!isTheme && ( +
+ onChange(e.target.value)} + className="w-7 h-7 rounded cursor-pointer border border-zinc-200 p-0.5 bg-white" /> + onChange(e.target.value)} + className="flex-1 text-xs font-mono px-2 py-1 border border-zinc-200 rounded outline-none focus:border-indigo-400" + placeholder="#000000 or CSS value" maxLength={30} /> +
+ )} +
+ ) +} + +function TextControl({ label, value, onChange, placeholder }: { + label: string; value: string; onChange: (v: string) => void; placeholder?: string +}) { + return ( +
+ {label} + onChange(e.target.value)} + placeholder={placeholder} + className="w-full text-xs px-2 py-1.5 border border-zinc-200 rounded outline-none focus:border-indigo-400 bg-white" + /> +
+ ) +} + +export function BlockEditor({ + block, customBlocks, theme, dataSources, + onContentChange, onStyleChange, onStyleReset, onThemeChange, onDeselect, +}: { + block: CanvasBlock + customBlocks: CustomBlockDef[] + theme: ThemeConfig + dataSources: DataSource[] + onContentChange: (instanceId: string, key: string, value: string) => void + onStyleChange: (instanceId: string, key: string, value: string) => void + onStyleReset: (instanceId: string) => void + onThemeChange: (t: ThemeConfig) => void + onDeselect: () => void +}) { + const [tab, setTab] = useState('content') + + const isCustom = block.blockId.startsWith('custom:') + const def = isCustom ? null : BLOCKS.find(b => b.id === block.blockId) + const customDef = isCustom ? customBlocks.find(b => `custom:${b.id}` === block.blockId) : null + const blockLabel = isCustom ? (customDef?.name ?? 'Custom') : (def?.label ?? block.blockId) + const contentFields = def?.contentFields ?? [] + const hasOverrides = Object.keys(block.styles).length > 0 + + const styleVal = (key: string) => block.styles[key] ?? '' + const setStyle = (key: string, val: string) => onStyleChange(block.instanceId, key, val) + const setContent = (key: string, val: string) => onContentChange(block.instanceId, key, val) + const contentVal = (key: string, fallback: string) => block.content[key] ?? fallback + + return ( +
+ {/* Header */} +
+
+

Editing

+

{blockLabel}

+
+ +
+ + {/* Tabs */} +
+ {([ + { id: 'content' as Tab, label: 'Content', icon: Type }, + { id: 'style' as Tab, label: 'Style', icon: Paintbrush }, + { id: 'theme' as Tab, label: 'Theme', icon: Palette }, + ]).map(({ id, label, icon: Icon }) => ( + + ))} +
+ +
+ {/* Content tab */} + {tab === 'content' && ( +
+ {isCustom ? ( +

Custom React components are edited in the code editor. Click the ✎ icon in the block library.

+ ) : contentFields.length === 0 ? ( +

This block has no editable content fields.

+ ) : ( +
+ {contentFields.map(field => { + const val = contentVal(field.key, def?.defaultContent?.[field.key] ?? '') + return ( +
+ + {field.type === 'textarea' ? ( +