mirror: import production source 1/3
This commit is contained in:
parent
0a29c1e463
commit
1b587d88e6
14
.dockerignore
Normal file
14
.dockerignore
Normal file
@ -0,0 +1,14 @@
|
||||
.git
|
||||
.gitignore
|
||||
.github
|
||||
**/node_modules
|
||||
**/dist
|
||||
**/.cache
|
||||
**/.vite
|
||||
**/coverage
|
||||
**/playwright-report
|
||||
**/test-results
|
||||
**/.env
|
||||
**/.env.*
|
||||
!**/.env.example
|
||||
*.log
|
||||
357
Backend/cmd/api/admin_settings.go
Normal file
357
Backend/cmd/api/admin_settings.go
Normal file
@ -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.",
|
||||
},
|
||||
}
|
||||
}
|
||||
75
Backend/cmd/api/api_models.go
Normal file
75
Backend/cmd/api/api_models.go
Normal file
@ -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"`
|
||||
}
|
||||
59
Backend/cmd/api/app.go
Normal file
59
Backend/cmd/api/app.go
Normal file
@ -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()
|
||||
}
|
||||
}
|
||||
398
Backend/cmd/api/assets.go
Normal file
398
Backend/cmd/api/assets.go
Normal file
@ -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
|
||||
}
|
||||
276
Backend/cmd/api/audit.go
Normal file
276
Backend/cmd/api/audit.go
Normal file
@ -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
|
||||
}
|
||||
473
Backend/cmd/api/authoring.go
Normal file
473
Backend/cmd/api/authoring.go
Normal file
@ -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
|
||||
}
|
||||
}
|
||||
352
Backend/cmd/api/authoring_site.go
Normal file
352
Backend/cmd/api/authoring_site.go
Normal file
@ -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
|
||||
}
|
||||
1363
Backend/cmd/api/billing.go
Normal file
1363
Backend/cmd/api/billing.go
Normal file
File diff suppressed because it is too large
Load Diff
507
Backend/cmd/api/blog.go
Normal file
507
Backend/cmd/api/blog.go
Normal file
@ -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")
|
||||
}
|
||||
841
Backend/cmd/api/communities.go
Normal file
841
Backend/cmd/api/communities.go
Normal file
@ -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
|
||||
}
|
||||
529
Backend/cmd/api/compliance.go
Normal file
529
Backend/cmd/api/compliance.go
Normal file
@ -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
|
||||
}
|
||||
143
Backend/cmd/api/config.go
Normal file
143
Backend/cmd/api/config.go
Normal file
@ -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
|
||||
}
|
||||
571
Backend/cmd/api/entitlements.go
Normal file
571
Backend/cmd/api/entitlements.go
Normal file
@ -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
|
||||
}
|
||||
561
Backend/cmd/api/forms.go
Normal file
561
Backend/cmd/api/forms.go
Normal file
@ -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
|
||||
}
|
||||
}
|
||||
25
Backend/cmd/api/http_helpers.go
Normal file
25
Backend/cmd/api/http_helpers.go
Normal file
@ -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)
|
||||
}
|
||||
225
Backend/cmd/api/keycloak_theme_deployment.go
Normal file
225
Backend/cmd/api/keycloak_theme_deployment.go
Normal file
@ -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 ""
|
||||
}
|
||||
274
Backend/cmd/api/keycloak_theme_form_schema.go
Normal file
274
Backend/cmd/api/keycloak_theme_form_schema.go
Normal file
@ -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
|
||||
}
|
||||
286
Backend/cmd/api/keycloak_theme_policy.go
Normal file
286
Backend/cmd/api/keycloak_theme_policy.go
Normal file
@ -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{"<script", "</script", "javascript:", "onerror=", "onload=", "<iframe", "srcdoc=", "data:text/html", "vbscript:"}
|
||||
for _, token := range unsafeTokens {
|
||||
if strings.Contains(normalized, token) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
case Doc:
|
||||
for _, child := range v {
|
||||
if hasUnsafeThemeValue(child) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
case map[string]any:
|
||||
for _, child := range v {
|
||||
if hasUnsafeThemeValue(child) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
case List:
|
||||
for _, child := range v {
|
||||
if hasUnsafeThemeValue(child) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
case []any:
|
||||
for _, child := range v {
|
||||
if hasUnsafeThemeValue(child) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
case []string:
|
||||
for _, child := range v {
|
||||
if hasUnsafeThemeValue(child) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func validateThemeURLFields(metadata Doc) error {
|
||||
for _, key := range []string{"supportUrl", "privacyUrl", "termsUrl"} {
|
||||
value := extractThemeString(metadata, key)
|
||||
if value == "" {
|
||||
continue
|
||||
}
|
||||
if err := validateThemeURL(value); err != nil {
|
||||
return errors.New("theme policy violation: " + key + " is invalid")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateThemeURL(raw string) error {
|
||||
parsed, err := url.Parse(strings.TrimSpace(raw))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if parsed.Scheme != "http" && parsed.Scheme != "https" {
|
||||
return errors.New("unsupported scheme")
|
||||
}
|
||||
if strings.TrimSpace(parsed.Host) == "" {
|
||||
return errors.New("missing host")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateThemeFontAndAssetPolicy(metadata Doc) error {
|
||||
font := strings.ToLower(extractThemeString(metadata, "fontFamily"))
|
||||
if font != "" {
|
||||
if _, ok := allowedThemeFontFamilies[font]; !ok {
|
||||
return errors.New("theme policy violation: fontFamily is not allowed")
|
||||
}
|
||||
}
|
||||
|
||||
for _, key := range []string{"logoAssetMimeType", "faviconAssetMimeType"} {
|
||||
mimeType := strings.ToLower(extractThemeString(metadata, key))
|
||||
if mimeType == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := allowedThemeAssetMimeTypes[mimeType]; !ok {
|
||||
return errors.New("theme policy violation: unsupported asset mime type")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateThemeLegalAndSecurityRequirements(metadata Doc) error {
|
||||
privacyURL := extractThemeString(metadata, "privacyUrl")
|
||||
termsURL := extractThemeString(metadata, "termsUrl")
|
||||
if privacyURL == "" || termsURL == "" {
|
||||
return errors.New("theme policy violation: privacyUrl and termsUrl are required for publish/export")
|
||||
}
|
||||
if err := validateThemeURL(privacyURL); err != nil {
|
||||
return errors.New("theme policy violation: privacyUrl is invalid")
|
||||
}
|
||||
if err := validateThemeURL(termsURL); err != nil {
|
||||
return errors.New("theme policy violation: termsUrl is invalid")
|
||||
}
|
||||
|
||||
footerText := extractThemeString(metadata, "footerText")
|
||||
securityText := extractThemeString(metadata, "securityText")
|
||||
if footerText == "" || securityText == "" {
|
||||
return errors.New("theme policy violation: footerText and securityText are required for publish/export")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateOrganizationThemePublicationApproval(claims *accessClaims, metadata Doc) error {
|
||||
approvalValue, ok := metadata["platformApproval"]
|
||||
if !ok {
|
||||
return errors.New("theme policy violation: organisation theme publication requires platform approval")
|
||||
}
|
||||
|
||||
approval, ok := normalizeThemeMetadata(approvalValue)
|
||||
if !ok || approval == nil {
|
||||
return errors.New("theme policy violation: invalid platformApproval payload")
|
||||
}
|
||||
|
||||
approvedBy := extractThemeString(approval, "approvedBy")
|
||||
approvedAt := extractThemeInt64(approval, "approvedAt")
|
||||
if approvedBy == "" || approvedAt <= 0 {
|
||||
return errors.New("theme policy violation: platformApproval.approvedBy and approvedAt are required")
|
||||
}
|
||||
|
||||
if !hasAnyRole(claims, "platform_owner", "platform_admin", "admin") {
|
||||
return errors.New("theme policy violation: platform approval must be applied by a platform role")
|
||||
}
|
||||
|
||||
if !hasPermission(claims, permKeycloakThemesPolicyManage) {
|
||||
return errors.New("theme policy violation: missing permission: keycloak_themes.policy.manage")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func isThemeExportRequested(existing, patch, metadata Doc) bool {
|
||||
if value, ok := patch["exportRequested"].(bool); ok && value {
|
||||
return true
|
||||
}
|
||||
if value, ok := existing["exportRequested"].(bool); ok && value {
|
||||
return true
|
||||
}
|
||||
if value, ok := metadata["exportRequested"].(bool); ok && value {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func extractThemeString(doc Doc, key string) string {
|
||||
value, ok := doc[key]
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
str, ok := value.(string)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(str)
|
||||
}
|
||||
|
||||
func extractThemeInt64(doc Doc, key string) int64 {
|
||||
value, ok := doc[key]
|
||||
if !ok {
|
||||
return 0
|
||||
}
|
||||
|
||||
switch v := value.(type) {
|
||||
case int:
|
||||
return int64(v)
|
||||
case int32:
|
||||
return int64(v)
|
||||
case int64:
|
||||
return v
|
||||
case float64:
|
||||
return int64(v)
|
||||
case string:
|
||||
parsed, err := strconv.ParseInt(strings.TrimSpace(v), 10, 64)
|
||||
if err == nil {
|
||||
return parsed
|
||||
}
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
258
Backend/cmd/api/keycloak_theme_preview_export.go
Normal file
258
Backend/cmd/api/keycloak_theme_preview_export.go
Normal file
@ -0,0 +1,258 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
func (a *app) previewKeycloakTheme(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)
|
||||
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)
|
||||
}
|
||||
554
Backend/cmd/api/keycloak_themes.go
Normal file
554
Backend/cmd/api/keycloak_themes.go
Normal file
@ -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"
|
||||
}
|
||||
1394
Backend/cmd/api/live_courses.go
Normal file
1394
Backend/cmd/api/live_courses.go
Normal file
File diff suppressed because it is too large
Load Diff
486
Backend/cmd/api/live_courses_payloads.go
Normal file
486
Backend/cmd/api/live_courses_payloads.go
Normal file
@ -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")
|
||||
}
|
||||
}
|
||||
40
Backend/cmd/api/main.go
Normal file
40
Backend/cmd/api/main.go
Normal file
@ -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)
|
||||
}
|
||||
}
|
||||
278
Backend/cmd/api/mcp_server.go
Normal file
278
Backend/cmd/api/mcp_server.go
Normal file
@ -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},
|
||||
})
|
||||
}
|
||||
198
Backend/cmd/api/middleware.go
Normal file
198
Backend/cmd/api/middleware.go
Normal file
@ -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 ""
|
||||
}
|
||||
1419
Backend/cmd/api/notifications.go
Normal file
1419
Backend/cmd/api/notifications.go
Normal file
File diff suppressed because it is too large
Load Diff
168
Backend/cmd/api/notifications_sendgrid.go
Normal file
168
Backend/cmd/api/notifications_sendgrid.go
Normal file
@ -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
|
||||
}
|
||||
489
Backend/cmd/api/pages.go
Normal file
489
Backend/cmd/api/pages.go
Normal file
@ -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
|
||||
}
|
||||
95
Backend/cmd/api/permissions.go
Normal file
95
Backend/cmd/api/permissions.go
Normal file
@ -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
|
||||
}
|
||||
120
Backend/cmd/api/platform_admin.go
Normal file
120
Backend/cmd/api/platform_admin.go
Normal file
@ -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)
|
||||
}
|
||||
898
Backend/cmd/api/postgres_store.go
Normal file
898
Backend/cmd/api/postgres_store.go
Normal file
@ -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)
|
||||
}
|
||||
60
Backend/cmd/api/publishing_policy.go
Normal file
60
Backend/cmd/api/publishing_policy.go
Normal file
@ -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
|
||||
}
|
||||
565
Backend/cmd/api/relational_schema.go
Normal file
565
Backend/cmd/api/relational_schema.go
Normal file
@ -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, `"`, `""`) + `"`
|
||||
}
|
||||
402
Backend/cmd/api/routes.go
Normal file
402
Backend/cmd/api/routes.go
Normal file
@ -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,
|
||||
})
|
||||
}
|
||||
26
Backend/cmd/api/routes_api_admin.go
Normal file
26
Backend/cmd/api/routes_api_admin.go
Normal file
@ -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)
|
||||
})
|
||||
}
|
||||
20
Backend/cmd/api/routes_api_assets.go
Normal file
20
Backend/cmd/api/routes_api_assets.go
Normal file
@ -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)
|
||||
})
|
||||
}
|
||||
32
Backend/cmd/api/routes_api_blog.go
Normal file
32
Backend/cmd/api/routes_api_blog.go
Normal file
@ -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)
|
||||
})
|
||||
}
|
||||
22
Backend/cmd/api/routes_api_communities.go
Normal file
22
Backend/cmd/api/routes_api_communities.go
Normal file
@ -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)
|
||||
})
|
||||
}
|
||||
64
Backend/cmd/api/routes_api_core.go
Normal file
64
Backend/cmd/api/routes_api_core.go
Normal file
@ -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)
|
||||
}
|
||||
15
Backend/cmd/api/routes_api_entitlements.go
Normal file
15
Backend/cmd/api/routes_api_entitlements.go
Normal file
@ -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)
|
||||
})
|
||||
}
|
||||
26
Backend/cmd/api/routes_api_forms.go
Normal file
26
Backend/cmd/api/routes_api_forms.go
Normal file
@ -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)
|
||||
})
|
||||
}
|
||||
18
Backend/cmd/api/routes_api_keycloak_themes.go
Normal file
18
Backend/cmd/api/routes_api_keycloak_themes.go
Normal file
@ -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)
|
||||
})
|
||||
}
|
||||
27
Backend/cmd/api/routes_api_learner.go
Normal file
27
Backend/cmd/api/routes_api_learner.go
Normal file
@ -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)
|
||||
})
|
||||
}
|
||||
34
Backend/cmd/api/routes_api_live_courses.go
Normal file
34
Backend/cmd/api/routes_api_live_courses.go
Normal file
@ -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)
|
||||
})
|
||||
}
|
||||
26
Backend/cmd/api/routes_api_notifications.go
Normal file
26
Backend/cmd/api/routes_api_notifications.go
Normal file
@ -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)
|
||||
})
|
||||
}
|
||||
12
Backend/cmd/api/routes_auth.go
Normal file
12
Backend/cmd/api/routes_auth.go
Normal file
@ -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)
|
||||
})
|
||||
}
|
||||
31
Backend/cmd/api/routes_public.go
Normal file
31
Backend/cmd/api/routes_public.go
Normal file
@ -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)
|
||||
}
|
||||
284
Backend/cmd/api/scorm_learner.go
Normal file
284
Backend/cmd/api/scorm_learner.go
Normal file
@ -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
|
||||
}
|
||||
}
|
||||
307
Backend/cmd/api/seo_public.go
Normal file
307
Backend/cmd/api/seo_public.go
Normal file
@ -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
|
||||
}
|
||||
307
Backend/cmd/api/sitemap_menus.go
Normal file
307
Backend/cmd/api/sitemap_menus.go
Normal file
@ -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
|
||||
}
|
||||
41
Backend/cmd/api/storage_types.go
Normal file
41
Backend/cmd/api/storage_types.go
Normal file
@ -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{}
|
||||
248
Backend/cmd/api/store.go
Normal file
248
Backend/cmd/api/store.go
Normal file
@ -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")
|
||||
}
|
||||
60
Backend/cmd/api/tenancy.go
Normal file
60
Backend/cmd/api/tenancy.go
Normal file
@ -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
|
||||
}
|
||||
68
Backend/cmd/api/web.go
Normal file
68
Backend/cmd/api/web.go
Normal file
@ -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
|
||||
}
|
||||
}
|
||||
21
Backend/go.mod
Normal file
21
Backend/go.mod
Normal file
@ -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
|
||||
)
|
||||
65
Backend/go.sum
Normal file
65
Backend/go.sum
Normal file
@ -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=
|
||||
50
Dockerfile
Normal file
50
Dockerfile
Normal file
@ -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"]
|
||||
120
Frontend/default_shadcn_theme.css
Normal file
120
Frontend/default_shadcn_theme.css
Normal file
@ -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);
|
||||
}
|
||||
22
Frontend/index.html
Normal file
22
Frontend/index.html
Normal file
@ -0,0 +1,22 @@
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Expand Theme Building</title>
|
||||
<meta name="description" content="Enables users to create and customize website themes easily within a CMS frontend, enhancing design flexibility for developers and content creators." />
|
||||
<meta name="robots" content="noindex, nofollow" />
|
||||
<style>html, body { height: 100%; margin: 0; } #root { height: 100%; }</style>
|
||||
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
|
||||
6132
Frontend/package-lock.json
generated
Normal file
6132
Frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
101
Frontend/package.json
Normal file
101
Frontend/package.json
Normal file
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
15
Frontend/postcss.config.mjs
Normal file
15
Frontend/postcss.config.mjs
Normal file
@ -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 {}
|
||||
16
Frontend/src/app/App.tsx
Normal file
16
Frontend/src/app/App.tsx
Normal file
@ -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 (
|
||||
<>
|
||||
<RouterProvider router={router} />
|
||||
<ConsentManager />
|
||||
</>
|
||||
)
|
||||
}
|
||||
164
Frontend/src/app/admin-assets.ts
Normal file
164
Frontend/src/app/admin-assets.ts
Normal file
@ -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<string, unknown>
|
||||
}
|
||||
|
||||
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<string, unknown> {
|
||||
if (typeof value === 'object' && value !== null) return value as Record<string, unknown>
|
||||
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, unknown>): 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<AssetRecord[]> {
|
||||
const rows = await apiRequest<unknown[]>('/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<AssetAccessEvent[]> {
|
||||
const rows = await apiRequest<unknown[]>(`/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<AssetClaim[]> {
|
||||
const rows = await apiRequest<unknown[]>(`/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<AssetRecord> {
|
||||
const row = await apiRequest<unknown>(`/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)
|
||||
)
|
||||
})
|
||||
}
|
||||
101
Frontend/src/app/admin-audit.ts
Normal file
101
Frontend/src/app/admin-audit.ts
Normal file
@ -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<string, unknown>
|
||||
}
|
||||
|
||||
export type AuditFilters = {
|
||||
action: string
|
||||
resource: string
|
||||
actor: string
|
||||
result: '' | 'success' | 'failed'
|
||||
since: string
|
||||
until: string
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> {
|
||||
if (typeof value === 'object' && value !== null) return value as Record<string, unknown>
|
||||
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<AuditEvent[]> {
|
||||
const query = buildAuditQuery(filters)
|
||||
const rows = await apiRequest<unknown[]>(`/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)
|
||||
))
|
||||
}
|
||||
244
Frontend/src/app/admin-content.ts
Normal file
244
Frontend/src/app/admin-content.ts
Normal file
@ -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 = <T>(resource: string) => Promise<T[]>
|
||||
|
||||
type DateLike = string | number | null | undefined
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> {
|
||||
if (typeof value === 'object' && value !== null) return value as Record<string, unknown>
|
||||
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<string, unknown>): 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<string, unknown>): 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, unknown>): 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<string, unknown>, 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<string, unknown>, 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<string, unknown>): 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<ContentPublishStatus, 'unknown'>,
|
||||
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<AdminContentData> {
|
||||
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<unknown>(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,
|
||||
}
|
||||
}
|
||||
159
Frontend/src/app/admin-dashboard.ts
Normal file
159
Frontend/src/app/admin-dashboard.ts
Normal file
@ -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 = <T>(resource: string) => Promise<T[]>
|
||||
|
||||
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<unknown>(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<AdminDashboardData> {
|
||||
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<UserRecord>('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<FormRecord>('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,
|
||||
}
|
||||
}
|
||||
293
Frontend/src/app/admin-learners.ts
Normal file
293
Frontend/src/app/admin-learners.ts
Normal file
@ -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 = <T>(resource: string) => Promise<T[]>
|
||||
|
||||
type StatusFilter = 'all' | LearnerUserStatus
|
||||
type RoleFilter = 'all' | LearnerRole
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> {
|
||||
if (typeof value === 'object' && value !== null) return value as Record<string, unknown>
|
||||
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<string, unknown>): 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<string, unknown>, ...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<string, string>): 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<string, string> {
|
||||
const map = new Map<string, string>()
|
||||
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<Array<{ id: string; title: string }>> {
|
||||
try {
|
||||
const rows = await loader<unknown>(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<AdminLearnerData> {
|
||||
const warnings: string[] = []
|
||||
|
||||
const [users, enrollments, pages, scorm] = await Promise.all([
|
||||
loader<unknown>('users'),
|
||||
loader<unknown>('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 }
|
||||
}
|
||||
67
Frontend/src/app/admin-nav.ts
Normal file
67
Frontend/src/app/admin-nav.ts
Normal file
@ -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',
|
||||
}
|
||||
})
|
||||
}
|
||||
156
Frontend/src/app/admin-settings.ts
Normal file
156
Frontend/src/app/admin-settings.ts
Normal file
@ -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<IntegrationKey, IntegrationStatus>
|
||||
settings: AdminEditableSettings
|
||||
environment: {
|
||||
notificationProvider: string
|
||||
keycloakAuthDisabled: boolean
|
||||
keycloakThemeDeploy: boolean
|
||||
databaseConnected: boolean
|
||||
}
|
||||
}
|
||||
|
||||
type RawIntegration = {
|
||||
configured?: unknown
|
||||
setupInstruction?: unknown
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
type RawSettingsResponse = {
|
||||
integrations?: Record<string, RawIntegration>
|
||||
settings?: Record<string, unknown>
|
||||
environment?: Record<string, unknown>
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> {
|
||||
if (typeof value === 'object' && value !== null) return value as Record<string, unknown>
|
||||
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<AdminSettingsData> {
|
||||
const result = await apiRequest<RawSettingsResponse>('/api/admin/settings/')
|
||||
return normalizeAdminSettingsResponse(result)
|
||||
}
|
||||
|
||||
export async function saveAdminSettings(settings: AdminEditableSettings): Promise<AdminEditableSettings> {
|
||||
const result = await apiRequest<{ settings?: unknown }>('/api/admin/settings/', {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(settings),
|
||||
})
|
||||
|
||||
const normalized = normalizeAdminSettingsResponse({ settings: result.settings })
|
||||
return normalized.settings
|
||||
}
|
||||
82
Frontend/src/app/api/client.ts
Normal file
82
Frontend/src/app/api/client.ts
Normal file
@ -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<T>(path: string, options: RequestOptions = {}): Promise<T> {
|
||||
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<T>(resource: string): Promise<T[]> {
|
||||
const result = await apiRequest<T[] | null>(`/api/${resource}`)
|
||||
return Array.isArray(result) ? result : []
|
||||
}
|
||||
|
||||
export async function getResource<T>(resource: string, id: string): Promise<T> {
|
||||
return await apiRequest<T>(`/api/${resource}/${id}`)
|
||||
}
|
||||
|
||||
export async function createResource<T>(resource: string, payload: Record<string, unknown>): Promise<T> {
|
||||
return await apiRequest<T>(`/api/${resource}`, { method: 'POST', body: JSON.stringify(payload) })
|
||||
}
|
||||
|
||||
export async function patchResource<T>(resource: string, id: string, payload: Record<string, unknown>): Promise<T> {
|
||||
return await apiRequest<T>(`/api/${resource}/${id}`, { method: 'PATCH', body: JSON.stringify(payload) })
|
||||
}
|
||||
|
||||
export async function deleteResource(resource: string, id: string): Promise<void> {
|
||||
await apiRequest<void>(`/api/${resource}/${id}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
export async function syncCollection<T extends { id: string }>(resource: string, desired: T[]): Promise<void> {
|
||||
const existing = await listResource<T>(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<string, unknown>)
|
||||
existingByID.delete(item.id)
|
||||
} else {
|
||||
await createResource(resource, item as unknown as Record<string, unknown>)
|
||||
}
|
||||
}
|
||||
|
||||
for (const stale of existingByID.values()) {
|
||||
await deleteResource(resource, stale.id)
|
||||
}
|
||||
}
|
||||
59
Frontend/src/app/auth/keycloak.ts
Normal file
59
Frontend/src/app/auth/keycloak.ts
Normal file
@ -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<void> {
|
||||
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<void> {
|
||||
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
|
||||
47
Frontend/src/app/blog-seo.ts
Normal file
47
Frontend/src/app/blog-seo.ts
Normal file
@ -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)
|
||||
}
|
||||
40
Frontend/src/app/blog-utils.ts
Normal file
40
Frontend/src/app/blog-utils.ts
Normal file
@ -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 ''
|
||||
}
|
||||
168
Frontend/src/app/blog.ts
Normal file
168
Frontend/src/app/blog.ts
Normal file
@ -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<string, unknown>
|
||||
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<string, unknown>).title) || undefined,
|
||||
description: asString((source.seo as Record<string, unknown>).description) || undefined,
|
||||
canonicalUrl: asString((source.seo as Record<string, unknown>).canonicalUrl) || undefined,
|
||||
}
|
||||
: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeTaxonomy(raw: unknown): BlogTaxonomy {
|
||||
const source = (raw ?? {}) as Record<string, unknown>
|
||||
return {
|
||||
id: asString(source.id),
|
||||
name: asString(source.name),
|
||||
slug: asString(source.slug) || undefined,
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchPublicBlogPosts(): Promise<BlogPost[]> {
|
||||
const result = await apiRequest<unknown[]>('/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<BlogPost> {
|
||||
const result = await apiRequest<unknown>(`/api/public/blog/posts/${encodeURIComponent(slug)}`, { skipAuth: true })
|
||||
return normalizePost(result)
|
||||
}
|
||||
|
||||
export async function fetchPublicBlogCategories(): Promise<BlogTaxonomy[]> {
|
||||
const result = await apiRequest<unknown[]>('/api/public/blog/categories', { skipAuth: true })
|
||||
return Array.isArray(result)
|
||||
? result.map(normalizeTaxonomy).filter(item => item.name)
|
||||
: []
|
||||
}
|
||||
|
||||
export async function fetchPublicBlogTags(): Promise<BlogTaxonomy[]> {
|
||||
const result = await apiRequest<unknown[]>('/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<BlogPost[]> {
|
||||
const result = await apiRequest<unknown[]>('/api/blog/posts')
|
||||
return Array.isArray(result)
|
||||
? result.map(normalizePost).filter(post => post.id && post.title)
|
||||
: []
|
||||
}
|
||||
|
||||
export async function fetchAdminBlogCategories(): Promise<BlogTaxonomy[]> {
|
||||
const result = await apiRequest<unknown[]>('/api/blog/categories')
|
||||
return Array.isArray(result)
|
||||
? result.map(normalizeTaxonomy).filter(item => item.name)
|
||||
: []
|
||||
}
|
||||
|
||||
export async function fetchAdminBlogTags(): Promise<BlogTaxonomy[]> {
|
||||
const result = await apiRequest<unknown[]>('/api/blog/tags')
|
||||
return Array.isArray(result)
|
||||
? result.map(normalizeTaxonomy).filter(item => item.name)
|
||||
: []
|
||||
}
|
||||
|
||||
export async function fetchAdminBlogAuthors(): Promise<BlogTaxonomy[]> {
|
||||
const result = await apiRequest<unknown[]>('/api/blog/authors')
|
||||
return Array.isArray(result)
|
||||
? result.map(normalizeTaxonomy).filter(item => item.name)
|
||||
: []
|
||||
}
|
||||
|
||||
export async function createAdminBlogPost(input: AdminBlogPostInput): Promise<BlogPost> {
|
||||
const result = await apiRequest<unknown>('/api/blog/posts', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(input),
|
||||
})
|
||||
return normalizePost(result)
|
||||
}
|
||||
|
||||
export async function updateAdminBlogPost(id: string, input: Partial<AdminBlogPostInput>): Promise<BlogPost> {
|
||||
const result = await apiRequest<unknown>(`/api/blog/posts/${encodeURIComponent(id)}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(input),
|
||||
})
|
||||
return normalizePost(result)
|
||||
}
|
||||
|
||||
export async function deleteAdminBlogPost(id: string): Promise<void> {
|
||||
await apiRequest<void>(`/api/blog/posts/${encodeURIComponent(id)}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
}
|
||||
158
Frontend/src/app/cms-utils.ts
Normal file
158
Frontend/src/app/cms-utils.ts
Normal file
@ -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<Page[]> {
|
||||
return await listResource<Page>('pages')
|
||||
}
|
||||
|
||||
export async function savePages(pages: Page[]): Promise<void> {
|
||||
await syncCollection('pages', pages)
|
||||
}
|
||||
|
||||
export async function savePage(page: Page, exists: boolean, expectedUpdatedAt?: number): Promise<Page> {
|
||||
const payload = {
|
||||
...page,
|
||||
...(exists && expectedUpdatedAt !== undefined ? { expectedUpdatedAt } : {}),
|
||||
} as unknown as Record<string, unknown>
|
||||
return exists
|
||||
? await patchResource<Page>('pages', page.id, payload)
|
||||
: await createResource<Page>('pages', payload)
|
||||
}
|
||||
|
||||
export async function deletePage(pageId: string): Promise<void> {
|
||||
await deleteResource('pages', pageId)
|
||||
}
|
||||
|
||||
export async function loadDataSources(): Promise<DataSource[]> {
|
||||
return await listResource<DataSource>('datasources')
|
||||
}
|
||||
|
||||
export async function saveDataSources(sources: DataSource[]): Promise<void> {
|
||||
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<string, unknown>[]; 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<string, string> = { '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<string, unknown>)[k] : undefined, json)
|
||||
: json
|
||||
const arr = Array.isArray(resolved) ? resolved : (resolved ? [resolved] : [])
|
||||
return { data: arr as Record<string, unknown>[], error: null }
|
||||
} catch (e) {
|
||||
return { data: [], error: String(e) }
|
||||
}
|
||||
}
|
||||
|
||||
// ── Theme presets ──────────────────────────────────────────────
|
||||
export const PRESET_THEMES: Record<string, ThemeConfig & { name: string }> = {
|
||||
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
|
||||
19
Frontend/src/app/components/AdminPageSurface.tsx
Normal file
19
Frontend/src/app/components/AdminPageSurface.tsx
Normal file
@ -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 <div className="min-h-0 w-full max-w-full overflow-hidden">{children}</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="mx-auto min-h-0 w-full max-w-full overflow-x-hidden overflow-y-auto rounded-[28px] border border-stone-200/80 bg-white/88 shadow-[0_20px_60px_rgba(65,48,31,0.08)] backdrop-blur">
|
||||
{children}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
246
Frontend/src/app/components/BlockEditor.tsx
Normal file
246
Frontend/src/app/components/BlockEditor.tsx
Normal file
@ -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 (
|
||||
<div className="mb-3">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="text-xs text-zinc-600">{label}</span>
|
||||
{themeVar && (
|
||||
<button
|
||||
onClick={() => onChange(isTheme ? '' : themeVar!)}
|
||||
className={`text-xs px-1.5 py-0.5 rounded transition-colors ${isTheme ? 'bg-indigo-100 text-indigo-600' : 'text-zinc-400 hover:text-indigo-500'}`}
|
||||
>
|
||||
{isTheme ? '✓ Using theme' : `Use ${themeLabel}`}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{!isTheme && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<input type="color" aria-label={`${label} color picker`} value={hexVal} onChange={e => onChange(e.target.value)}
|
||||
className="w-7 h-7 rounded cursor-pointer border border-zinc-200 p-0.5 bg-white" />
|
||||
<input type="text" aria-label={`${label} value`} value={value} onChange={e => 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} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TextControl({ label, value, onChange, placeholder }: {
|
||||
label: string; value: string; onChange: (v: string) => void; placeholder?: string
|
||||
}) {
|
||||
return (
|
||||
<div className="mb-2">
|
||||
<span className="text-xs text-zinc-600 block mb-1">{label}</span>
|
||||
<input type="text" aria-label={label} value={value} onChange={e => 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"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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<Tab>('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 (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Header */}
|
||||
<div className="px-4 pt-4 pb-3 border-b border-zinc-100 flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-xs font-semibold text-zinc-400 uppercase tracking-widest mb-0.5">Editing</h2>
|
||||
<p className="text-sm font-semibold text-zinc-800">{blockLabel}</p>
|
||||
</div>
|
||||
<button onClick={onDeselect} aria-label="Close block editor"
|
||||
className="w-7 h-7 flex items-center justify-center rounded-md hover:bg-zinc-100 text-zinc-400 transition-colors">
|
||||
<X size={13} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex border-b border-zinc-100 px-3 pt-2 gap-0.5">
|
||||
{([
|
||||
{ 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 }) => (
|
||||
<button key={id} onClick={() => setTab(id)}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-t-md text-xs font-medium transition-all border-b-2 -mb-px ${
|
||||
tab === id ? 'border-indigo-500 text-indigo-600 bg-indigo-50/50' : 'border-transparent text-zinc-500 hover:text-zinc-700'
|
||||
}`}>
|
||||
<Icon size={11} />{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{/* Content tab */}
|
||||
{tab === 'content' && (
|
||||
<div className="p-4">
|
||||
{isCustom ? (
|
||||
<p className="text-xs text-zinc-400 text-center py-4">Custom React components are edited in the code editor. Click the ✎ icon in the block library.</p>
|
||||
) : contentFields.length === 0 ? (
|
||||
<p className="text-xs text-zinc-400 text-center py-4">This block has no editable content fields.</p>
|
||||
) : (
|
||||
<div>
|
||||
{contentFields.map(field => {
|
||||
const val = contentVal(field.key, def?.defaultContent?.[field.key] ?? '')
|
||||
return (
|
||||
<div key={field.key} className="mb-3">
|
||||
<label className="text-xs font-medium text-zinc-600 block mb-1">{field.label}</label>
|
||||
{field.type === 'textarea' ? (
|
||||
<textarea value={val} onChange={e => setContent(field.key, e.target.value)}
|
||||
placeholder={field.placeholder}
|
||||
className="w-full text-xs px-2 py-1.5 border border-zinc-200 rounded outline-none focus:border-indigo-400 bg-white resize-none"
|
||||
style={{ height: '72px' }} />
|
||||
) : field.type === 'select' ? (
|
||||
<select value={val} onChange={e => setContent(field.key, e.target.value)}
|
||||
className="w-full text-xs px-2 py-1.5 border border-zinc-200 rounded outline-none focus:border-indigo-400 bg-white">
|
||||
{field.options?.map(opt => <option key={opt} value={opt}>{opt}</option>)}
|
||||
</select>
|
||||
) : field.type === 'image' ? (
|
||||
<div>
|
||||
<input type="url" value={val}
|
||||
onChange={e => setContent(field.key, e.target.value)}
|
||||
placeholder={field.placeholder ?? 'https://example.com/image.jpg'}
|
||||
className="w-full text-xs px-2 py-1.5 border border-zinc-200 rounded outline-none focus:border-indigo-400 bg-white mb-1.5" />
|
||||
{val && (
|
||||
<div className="rounded border border-zinc-200 overflow-hidden bg-zinc-50" style={{ height: '80px' }}>
|
||||
<img src={val} alt="preview" className="w-full h-full object-cover" onError={e => { (e.target as HTMLImageElement).style.display = 'none' }} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : field.type === 'datasource' ? (
|
||||
<div>
|
||||
<select value={val} onChange={e => setContent(field.key, e.target.value)}
|
||||
className="w-full text-xs px-2 py-1.5 border border-zinc-200 rounded outline-none focus:border-indigo-400 bg-white">
|
||||
<option value="">— Select data source —</option>
|
||||
{dataSources.map(ds => <option key={ds.id} value={ds.id}>{ds.name}</option>)}
|
||||
</select>
|
||||
{dataSources.length === 0 && (
|
||||
<p className="text-xs text-zinc-400 mt-1">No data sources yet. Click "Data" in the header to add one.</p>
|
||||
)}
|
||||
</div>
|
||||
) : field.type === 'datafield' ? (
|
||||
<input type="text" value={val}
|
||||
onChange={e => setContent(field.key, e.target.value)}
|
||||
placeholder={field.placeholder ?? field.hint ?? 'field name'}
|
||||
className="w-full text-xs px-2 py-1.5 border border-zinc-200 rounded outline-none focus:border-indigo-400 bg-white font-mono" />
|
||||
) : (
|
||||
<input type={field.type === 'url' ? 'url' : 'text'} value={val}
|
||||
onChange={e => setContent(field.key, e.target.value)}
|
||||
placeholder={field.placeholder}
|
||||
className="w-full text-xs px-2 py-1.5 border border-zinc-200 rounded outline-none focus:border-indigo-400 bg-white" />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Style tab */}
|
||||
{tab === 'style' && (
|
||||
<div className="p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="text-xs font-semibold text-zinc-500 uppercase tracking-wider">Component Overrides</span>
|
||||
{hasOverrides && (
|
||||
<button onClick={() => onStyleReset(block.instanceId)}
|
||||
className="flex items-center gap-1 text-xs text-zinc-400 hover:text-red-500 transition-colors">
|
||||
<RotateCcw size={9} /> Reset
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ColorControl label="Background" value={styleVal('backgroundColor')}
|
||||
onChange={v => setStyle('backgroundColor', v)}
|
||||
themeVar="var(--cms-bg)" themeLabel="bg" />
|
||||
<ColorControl label="Text color" value={styleVal('color')}
|
||||
onChange={v => setStyle('color', v)}
|
||||
themeVar="var(--cms-fg)" themeLabel="fg" />
|
||||
|
||||
<div className="border-t border-zinc-100 my-3" />
|
||||
<p className="text-xs font-semibold text-zinc-500 uppercase tracking-wider mb-3">Spacing</p>
|
||||
<TextControl label="Padding" value={styleVal('padding')} onChange={v => setStyle('padding', v)} placeholder="1rem 2rem" />
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<TextControl label="Margin top" value={styleVal('marginTop')} onChange={v => setStyle('marginTop', v)} placeholder="1rem" />
|
||||
<TextControl label="Margin bottom" value={styleVal('marginBottom')} onChange={v => setStyle('marginBottom', v)} placeholder="1rem" />
|
||||
</div>
|
||||
|
||||
<div className="border-t border-zinc-100 my-3" />
|
||||
<p className="text-xs font-semibold text-zinc-500 uppercase tracking-wider mb-3">Shape & Layout</p>
|
||||
<TextControl label="Border radius" value={styleVal('borderRadius')} onChange={v => setStyle('borderRadius', v)} placeholder="0.5rem" />
|
||||
<TextControl label="Max width" value={styleVal('maxWidth')} onChange={v => setStyle('maxWidth', v)} placeholder="640px" />
|
||||
|
||||
<div className="border-t border-zinc-100 my-3" />
|
||||
<p className="text-xs font-semibold text-zinc-500 uppercase tracking-wider mb-2">Opacity</p>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<input type="range" min={0} max={1} step={0.05}
|
||||
value={parseFloat(styleVal('opacity') || '1')}
|
||||
onChange={e => setStyle('opacity', e.target.value)}
|
||||
className="flex-1 accent-indigo-500" />
|
||||
<span className="text-xs font-mono text-zinc-500 w-8 text-right">{styleVal('opacity') || '1'}</span>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-zinc-100 my-3" />
|
||||
<p className="text-xs font-semibold text-zinc-500 uppercase tracking-wider mb-2">Border</p>
|
||||
<TextControl label="Border" value={styleVal('border')} onChange={v => setStyle('border', v)} placeholder="1px solid #e4e4e7" />
|
||||
<TextControl label="Box shadow" value={styleVal('boxShadow')} onChange={v => setStyle('boxShadow', v)} placeholder="0 4px 24px rgba(0,0,0,0.1)" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Theme tab */}
|
||||
{tab === 'theme' && (
|
||||
<div>
|
||||
<div className="px-4 py-2 bg-indigo-50 border-b border-indigo-100">
|
||||
<p className="text-xs text-indigo-600">Changes here apply to all blocks via CSS variables.</p>
|
||||
</div>
|
||||
<ThemeBuilder theme={theme} onChange={onThemeChange} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
257
Frontend/src/app/components/BlockLibrary.tsx
Normal file
257
Frontend/src/app/components/BlockLibrary.tsx
Normal file
@ -0,0 +1,257 @@
|
||||
import { useState } from 'react'
|
||||
import { useDrag } from 'react-dnd'
|
||||
import { Search, ChevronDown, ChevronRight, Plus, Code2, Trash2 } from 'lucide-react'
|
||||
import { BLOCKS, CATEGORIES, type BlockDef } from './blocks'
|
||||
import type { CustomBlockDef } from '../App'
|
||||
|
||||
export const DRAG_TYPE = 'CMS_BLOCK'
|
||||
|
||||
function DraggableBlockItem({ block, onAdd }: { block: BlockDef; onAdd: (id: string) => void }) {
|
||||
const [{ isDragging }, drag] = useDrag(() => ({
|
||||
type: DRAG_TYPE,
|
||||
item: { blockId: block.id },
|
||||
collect: (monitor) => ({ isDragging: monitor.isDragging() }),
|
||||
}))
|
||||
|
||||
const Icon = block.icon
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
ref={drag as unknown as React.Ref<HTMLButtonElement>}
|
||||
onClick={() => onAdd(block.id)}
|
||||
title={`Click or drag to add "${block.label}"`}
|
||||
aria-label={`Add ${block.label} block`}
|
||||
style={{ opacity: isDragging ? 0.4 : 1 }}
|
||||
className="flex w-full items-center gap-2.5 px-3 py-2 rounded-md cursor-grab active:cursor-grabbing hover:bg-zinc-100 focus-visible:outline focus-visible:outline-2 focus-visible:outline-indigo-500 transition-colors group select-none"
|
||||
>
|
||||
<div className="w-7 h-7 rounded flex items-center justify-center bg-zinc-100 group-hover:bg-white transition-colors flex-shrink-0">
|
||||
<Icon size={14} className="text-zinc-500" />
|
||||
</div>
|
||||
<span className="text-sm text-zinc-700">{block.label}</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function DraggableCustomItem({
|
||||
block,
|
||||
onAdd,
|
||||
onDelete,
|
||||
}: {
|
||||
block: CustomBlockDef
|
||||
onAdd: (id: string) => void
|
||||
onDelete: (id: string) => void
|
||||
}) {
|
||||
const blockId = `custom:${block.id}`
|
||||
const [{ isDragging }, drag] = useDrag(() => ({
|
||||
type: DRAG_TYPE,
|
||||
item: { blockId },
|
||||
collect: (monitor) => ({ isDragging: monitor.isDragging() }),
|
||||
}))
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={drag as unknown as React.Ref<HTMLDivElement>}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={`Add ${block.name} custom block`}
|
||||
onKeyDown={event => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault()
|
||||
onAdd(blockId)
|
||||
}
|
||||
}}
|
||||
style={{ opacity: isDragging ? 0.4 : 1 }}
|
||||
className="flex items-center gap-2 px-3 py-2 rounded-md hover:bg-zinc-100 focus-visible:outline focus-visible:outline-2 focus-visible:outline-indigo-500 transition-colors group select-none cursor-grab active:cursor-grabbing"
|
||||
>
|
||||
<div
|
||||
className="w-7 h-7 rounded flex items-center justify-center bg-indigo-50 group-hover:bg-indigo-100 transition-colors flex-shrink-0"
|
||||
onClick={() => onAdd(blockId)}
|
||||
>
|
||||
<Code2 size={13} className="text-indigo-500" />
|
||||
</div>
|
||||
<span
|
||||
className="text-sm text-zinc-700 flex-1 truncate"
|
||||
onClick={() => onAdd(blockId)}
|
||||
>
|
||||
{block.name}
|
||||
</span>
|
||||
<div className="hidden group-hover:flex group-focus-within:flex items-center gap-0.5 flex-shrink-0">
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onDelete(block.id) }}
|
||||
title="Delete component"
|
||||
className="w-6 h-6 flex items-center justify-center rounded hover:bg-red-50 text-zinc-400 hover:text-red-500 transition-colors"
|
||||
>
|
||||
<Trash2 size={11} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CollapsibleCategory({
|
||||
label,
|
||||
blocks,
|
||||
onAdd,
|
||||
}: {
|
||||
label: string
|
||||
blocks: BlockDef[]
|
||||
onAdd: (id: string) => void
|
||||
}) {
|
||||
const [open, setOpen] = useState(true)
|
||||
return (
|
||||
<div className="mb-1">
|
||||
<button
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
className="w-full flex items-center justify-between px-4 py-1.5 hover:bg-zinc-50 transition-colors group"
|
||||
>
|
||||
<span className="text-xs font-semibold text-zinc-400 uppercase tracking-wider group-hover:text-zinc-500 transition-colors">
|
||||
{label}
|
||||
</span>
|
||||
{open
|
||||
? <ChevronDown size={12} className="text-zinc-300 group-hover:text-zinc-400 transition-colors" />
|
||||
: <ChevronRight size={12} className="text-zinc-300 group-hover:text-zinc-400 transition-colors" />
|
||||
}
|
||||
</button>
|
||||
{open && (
|
||||
<div className="px-2">
|
||||
{blocks.map((block) => (
|
||||
<DraggableBlockItem key={block.id} block={block} onAdd={onAdd} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CustomSection({
|
||||
customBlocks,
|
||||
onAdd,
|
||||
onDelete,
|
||||
}: {
|
||||
customBlocks: CustomBlockDef[]
|
||||
onAdd: (id: string) => void
|
||||
onDelete: (id: string) => void
|
||||
}) {
|
||||
const [open, setOpen] = useState(true)
|
||||
|
||||
return (
|
||||
<div className="mb-1">
|
||||
<div className="flex items-center">
|
||||
<button
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
className="flex-1 flex items-center justify-between px-4 py-1.5 hover:bg-zinc-50 transition-colors group"
|
||||
>
|
||||
<span className="text-xs font-semibold text-zinc-400 uppercase tracking-wider group-hover:text-zinc-500 transition-colors flex items-center gap-1.5">
|
||||
<Code2 size={11} className="text-indigo-400" />
|
||||
Custom
|
||||
{customBlocks.length > 0 && (
|
||||
<span className="bg-indigo-100 text-indigo-500 rounded-full px-1.5 py-0 text-xs font-bold leading-4">
|
||||
{customBlocks.length}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{open
|
||||
? <ChevronDown size={12} className="text-zinc-300 group-hover:text-zinc-400 transition-colors" />
|
||||
: <ChevronRight size={12} className="text-zinc-300 group-hover:text-zinc-400 transition-colors" />
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{open && (
|
||||
<div className="px-2">
|
||||
{customBlocks.length === 0 ? (
|
||||
<div className="px-3 py-3 text-center">
|
||||
<p className="text-xs text-zinc-400 mb-2">No custom components yet</p>
|
||||
</div>
|
||||
) : (
|
||||
customBlocks.map((b) => (
|
||||
<DraggableCustomItem
|
||||
key={b.id}
|
||||
block={b}
|
||||
onAdd={onAdd}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
<button
|
||||
disabled
|
||||
title="Requires an isolated custom component sandbox"
|
||||
className="mt-1 flex w-full cursor-not-allowed items-center justify-center gap-1.5 rounded-md border border-dashed border-zinc-200 px-3 py-2 text-xs font-medium text-zinc-400"
|
||||
>
|
||||
<Plus size={12} />
|
||||
Sandbox required
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function BlockLibrary({
|
||||
onAddBlock,
|
||||
customBlocks,
|
||||
onDeleteCustomBlock,
|
||||
}: {
|
||||
onAddBlock: (blockId: string) => void
|
||||
customBlocks: CustomBlockDef[]
|
||||
onDeleteCustomBlock: (id: string) => void
|
||||
}) {
|
||||
const [query, setQuery] = useState('')
|
||||
|
||||
const filtered = query.trim()
|
||||
? BLOCKS.filter((b) => b.label.toLowerCase().includes(query.toLowerCase()))
|
||||
: BLOCKS
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Header */}
|
||||
<div className="px-4 pt-4 pb-3 border-b border-zinc-100">
|
||||
<h2 className="text-xs font-semibold text-zinc-400 uppercase tracking-widest mb-3">Blocks</h2>
|
||||
<div className="relative">
|
||||
<Search size={13} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-zinc-400" />
|
||||
<input
|
||||
type="text"
|
||||
aria-label="Search blocks"
|
||||
placeholder="Search blocks…"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
className="w-full pl-8 pr-3 py-1.5 text-sm bg-zinc-100 rounded-md outline-none placeholder:text-zinc-400 focus:ring-1 focus:ring-zinc-300"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Block list */}
|
||||
<div className="flex-1 overflow-y-auto py-2">
|
||||
{query.trim() ? (
|
||||
<div className="px-2">
|
||||
{filtered.length === 0 ? (
|
||||
<p className="text-xs text-zinc-400 px-3 py-4 text-center">No blocks match "{query}"</p>
|
||||
) : (
|
||||
filtered.map((block) => (
|
||||
<DraggableBlockItem key={block.id} block={block} onAdd={onAddBlock} />
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<CustomSection
|
||||
customBlocks={customBlocks}
|
||||
onAdd={onAddBlock}
|
||||
onDelete={onDeleteCustomBlock}
|
||||
/>
|
||||
<div className="my-2 mx-4 border-t border-zinc-100" />
|
||||
{CATEGORIES.map((cat) => {
|
||||
const catBlocks = BLOCKS.filter((b) => b.category === cat)
|
||||
return <CollapsibleCategory key={cat} label={cat} blocks={catBlocks} onAdd={onAddBlock} />
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="px-4 py-3 border-t border-zinc-100">
|
||||
<p className="text-xs text-zinc-400 text-center">Click or drag blocks onto the canvas</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
303
Frontend/src/app/components/Canvas.tsx
Normal file
303
Frontend/src/app/components/Canvas.tsx
Normal file
@ -0,0 +1,303 @@
|
||||
import { useDrop } from 'react-dnd'
|
||||
import { Trash2, ChevronUp, ChevronDown, GripVertical, Plus } from 'lucide-react'
|
||||
import { DRAG_TYPE } from './BlockLibrary'
|
||||
import { BLOCKS, type BlockDef } from './blocks'
|
||||
import { CustomBlockRenderer } from './CustomBlockRenderer'
|
||||
import type { ThemeConfig, CanvasBlock, CustomBlockDef, DataSource, DataCacheEntry } from '../App'
|
||||
|
||||
function blockById(id: string): BlockDef | undefined {
|
||||
return BLOCKS.find((b) => b.id === id)
|
||||
}
|
||||
|
||||
function SlotDropZone({
|
||||
blocks, slotIndex, parentInstanceId,
|
||||
theme, customBlocks, selectedId, dataCache,
|
||||
onSelect, onRemove, onMove, onDrop,
|
||||
}: {
|
||||
blocks: CanvasBlock[]
|
||||
slotIndex: number
|
||||
parentInstanceId: string
|
||||
theme: ThemeConfig
|
||||
customBlocks: CustomBlockDef[]
|
||||
selectedId: string | null
|
||||
dataCache: Record<string, DataCacheEntry>
|
||||
onSelect: (id: string | null) => void
|
||||
onRemove: (instanceId: string) => void
|
||||
onMove: (instanceId: string, dir: 'up' | 'down') => void
|
||||
onDrop: (blockId: string, parentInstanceId?: string, slotIndex?: number) => void
|
||||
}) {
|
||||
const [{ isOver, canDrop }, drop] = useDrop<{ blockId: string }, { dropped: boolean }, { isOver: boolean; canDrop: boolean }>({
|
||||
accept: DRAG_TYPE,
|
||||
drop: (item, monitor) => {
|
||||
if (!monitor.isOver({ shallow: true })) return undefined
|
||||
onDrop(item.blockId, parentInstanceId, slotIndex)
|
||||
return { dropped: true }
|
||||
},
|
||||
collect: (monitor) => ({
|
||||
isOver: monitor.isOver({ shallow: true }),
|
||||
canDrop: monitor.canDrop(),
|
||||
}),
|
||||
})
|
||||
|
||||
const dropActive = isOver && canDrop
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={drop as unknown as React.Ref<HTMLDivElement>}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
minHeight: 64, borderRadius: '4px',
|
||||
border: dropActive ? '2px dashed #6366f1' : '2px dashed transparent',
|
||||
background: dropActive ? 'rgba(99,102,241,0.05)' : 'transparent',
|
||||
transition: 'all 0.12s', padding: '2px',
|
||||
}}
|
||||
>
|
||||
{blocks.length === 0 ? (
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', minHeight: 60, color: 'var(--cms-muted-fg)', fontSize: '0.72rem', pointerEvents: 'none', gap: '0.375rem' }}>
|
||||
<Plus size={12} />{dropActive ? 'Release to drop' : 'Drop blocks here'}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.375rem' }}>
|
||||
{blocks.map((block, idx) => (
|
||||
<CanvasItem
|
||||
key={block.instanceId}
|
||||
block={block}
|
||||
selected={selectedId === block.instanceId}
|
||||
isFirst={idx === 0}
|
||||
isLast={idx === blocks.length - 1}
|
||||
theme={theme}
|
||||
customBlocks={customBlocks}
|
||||
selectedId={selectedId}
|
||||
dataCache={dataCache}
|
||||
onSelect={onSelect}
|
||||
onRemove={onRemove}
|
||||
onMove={onMove}
|
||||
onDrop={onDrop}
|
||||
/>
|
||||
))}
|
||||
{dropActive && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '0.5rem', color: '#6366f1', fontSize: '0.75rem', fontWeight: 500, borderTop: '1px dashed #c7d2fe' }}>
|
||||
<Plus size={12} style={{ marginRight: '0.25rem' }} />Drop to add
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CanvasItem({
|
||||
block, selected, isFirst, isLast,
|
||||
theme, customBlocks, selectedId, dataCache,
|
||||
onSelect, onRemove, onMove, onDrop,
|
||||
}: {
|
||||
block: CanvasBlock
|
||||
selected: boolean
|
||||
isFirst: boolean
|
||||
isLast: boolean
|
||||
theme: ThemeConfig
|
||||
customBlocks: CustomBlockDef[]
|
||||
selectedId: string | null
|
||||
dataCache: Record<string, DataCacheEntry>
|
||||
onSelect: (id: string | null) => void
|
||||
onRemove: (instanceId: string) => void
|
||||
onMove: (instanceId: string, dir: 'up' | 'down') => void
|
||||
onDrop: (blockId: string, parentInstanceId?: string, slotIndex?: number) => void
|
||||
}) {
|
||||
const isCustom = block.blockId.startsWith('custom:')
|
||||
const customId = isCustom ? block.blockId.slice(7) : null
|
||||
const customDef = customId ? customBlocks.find((b) => b.id === customId) : null
|
||||
const def = isCustom ? null : blockById(block.blockId)
|
||||
const label = isCustom ? (customDef?.name ?? 'Custom') : def?.label ?? ''
|
||||
|
||||
if (!isCustom && !def) return null
|
||||
if (isCustom && !customDef) return null
|
||||
|
||||
const slotNodes = def?.slots
|
||||
? Array.from({ length: def.slots }, (_, i) => (
|
||||
<SlotDropZone
|
||||
key={i}
|
||||
blocks={block.slots?.[i] ?? []}
|
||||
slotIndex={i}
|
||||
parentInstanceId={block.instanceId}
|
||||
theme={theme}
|
||||
customBlocks={customBlocks}
|
||||
selectedId={selectedId}
|
||||
dataCache={dataCache}
|
||||
onSelect={onSelect}
|
||||
onRemove={onRemove}
|
||||
onMove={onMove}
|
||||
onDrop={onDrop}
|
||||
/>
|
||||
))
|
||||
: undefined
|
||||
|
||||
const blockData = def?.isDynamic
|
||||
? dataCache[block.content.dataSource ?? '']?.data ?? []
|
||||
: undefined
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={(e) => { e.stopPropagation(); onSelect(block.instanceId) }}
|
||||
onKeyDown={event => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
onSelect(block.instanceId)
|
||||
}
|
||||
}}
|
||||
role="group"
|
||||
tabIndex={0}
|
||||
aria-label={`Select ${label} block`}
|
||||
aria-current={selected ? 'true' : undefined}
|
||||
style={{ position: 'relative', outline: selected ? '2px solid #6366f1' : '2px solid transparent', outlineOffset: '2px', borderRadius: '6px', cursor: 'default', transition: 'outline 0.1s' }}
|
||||
className="group focus-visible:outline focus-visible:outline-2 focus-visible:outline-indigo-500"
|
||||
>
|
||||
{selected && (
|
||||
<div
|
||||
style={{ position: 'absolute', top: '-36px', right: 0, zIndex: 10, display: 'flex', gap: '4px', background: '#6366f1', borderRadius: '6px', padding: '4px' }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<button onClick={() => onMove(block.instanceId, 'up')} disabled={isFirst} title="Move up"
|
||||
style={{ width: 24, height: 24, display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'transparent', border: 'none', borderRadius: '4px', cursor: isFirst ? 'not-allowed' : 'pointer', opacity: isFirst ? 0.4 : 1, color: '#fff' }}>
|
||||
<ChevronUp size={14} />
|
||||
</button>
|
||||
<button onClick={() => onMove(block.instanceId, 'down')} disabled={isLast} title="Move down"
|
||||
style={{ width: 24, height: 24, display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'transparent', border: 'none', borderRadius: '4px', cursor: isLast ? 'not-allowed' : 'pointer', opacity: isLast ? 0.4 : 1, color: '#fff' }}>
|
||||
<ChevronDown size={14} />
|
||||
</button>
|
||||
<div style={{ width: 1, background: 'rgba(255,255,255,0.3)', margin: '4px 0' }} />
|
||||
<div style={{ width: 24, height: 24, display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'rgba(255,255,255,0.8)' }}>
|
||||
<GripVertical size={12} />
|
||||
</div>
|
||||
<div style={{ width: 1, background: 'rgba(255,255,255,0.3)', margin: '4px 0' }} />
|
||||
<button onClick={() => onRemove(block.instanceId)} title="Remove block"
|
||||
style={{ width: 24, height: 24, display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'transparent', border: 'none', borderRadius: '4px', cursor: 'pointer', color: '#fca5a5' }}>
|
||||
<Trash2 size={13} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!selected && (
|
||||
<div style={{ position: 'absolute', top: 0, left: 0, zIndex: 5, opacity: 0, pointerEvents: 'none', transition: 'opacity 0.15s' }} className="group-hover:opacity-100">
|
||||
<span style={{ background: isCustom ? 'rgba(79,70,229,0.9)' : 'rgba(99,102,241,0.9)', color: '#fff', fontSize: '0.65rem', fontWeight: 600, padding: '2px 6px', borderRadius: '4px 0 4px 0', letterSpacing: '0.05em', textTransform: 'uppercase' }}>
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{
|
||||
fontFamily: theme.fontFamily,
|
||||
['--cms-bg' as string]: theme.background,
|
||||
['--cms-fg' as string]: theme.foreground,
|
||||
['--cms-primary' as string]: theme.primary,
|
||||
['--cms-primary-fg' as string]: theme.primaryForeground,
|
||||
['--cms-muted' as string]: theme.muted,
|
||||
['--cms-muted-fg' as string]: theme.mutedForeground,
|
||||
['--cms-border' as string]: theme.border,
|
||||
['--cms-accent' as string]: theme.accent,
|
||||
['--cms-radius' as string]: `${theme.radius}rem`,
|
||||
['--cms-font' as string]: theme.fontFamily,
|
||||
} as React.CSSProperties}>
|
||||
{isCustom && customDef
|
||||
? <CustomBlockRenderer code={customDef.code} theme={theme} />
|
||||
: <div style={block.styles as React.CSSProperties}>{def!.render({ ...(def!.defaultContent ?? {}), ...block.content }, slotNodes, blockData)}</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function Canvas({
|
||||
blocks, customBlocks, theme, selectedId, dataSources: _dataSources, dataCache,
|
||||
onSelect, onDrop, onRemove, onMove,
|
||||
}: {
|
||||
blocks: CanvasBlock[]
|
||||
customBlocks: CustomBlockDef[]
|
||||
theme: ThemeConfig
|
||||
selectedId: string | null
|
||||
dataSources: DataSource[]
|
||||
dataCache: Record<string, DataCacheEntry>
|
||||
onSelect: (id: string | null) => void
|
||||
onDrop: (blockId: string, parentInstanceId?: string, slotIndex?: number) => void
|
||||
onRemove: (instanceId: string) => void
|
||||
onMove: (instanceId: string, dir: 'up' | 'down') => void
|
||||
}) {
|
||||
const [{ isOver, canDrop }, drop] = useDrop<{ blockId: string }, void, { isOver: boolean; canDrop: boolean }>({
|
||||
accept: DRAG_TYPE,
|
||||
drop: (item, monitor) => {
|
||||
if (monitor.didDrop()) return
|
||||
onDrop(item.blockId)
|
||||
},
|
||||
collect: (monitor) => ({
|
||||
isOver: monitor.isOver(),
|
||||
canDrop: monitor.canDrop(),
|
||||
}),
|
||||
})
|
||||
|
||||
const dropActive = isOver && canDrop
|
||||
|
||||
return (
|
||||
<div className="min-h-full flex flex-col" style={{ background: '#f1f5f9' }} onClick={() => onSelect(null)}>
|
||||
<div className="flex items-center justify-between px-6 py-2 bg-white border-b border-zinc-200 sticky top-0 z-20">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-2.5 h-2.5 rounded-full bg-red-400" />
|
||||
<div className="w-2.5 h-2.5 rounded-full bg-yellow-400" />
|
||||
<div className="w-2.5 h-2.5 rounded-full bg-green-400" />
|
||||
<span className="ml-2 text-xs text-zinc-400 font-medium">Canvas Preview</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{blocks.length > 0 && <span className="text-xs text-zinc-400">{blocks.length} block{blocks.length !== 1 ? 's' : ''}</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 flex justify-center px-8 py-8">
|
||||
<div
|
||||
style={{ background: theme.background, width: '100%', maxWidth: '860px', minHeight: '600px', borderRadius: '8px', boxShadow: '0 4px 24px rgba(0,0,0,0.08)', padding: '2rem', boxSizing: 'border-box', position: 'relative' }}
|
||||
ref={drop as unknown as React.Ref<HTMLDivElement>}
|
||||
>
|
||||
{blocks.length === 0 ? (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', minHeight: '480px', border: `2px dashed ${dropActive ? '#6366f1' : '#cbd5e1'}`, borderRadius: '8px', transition: 'border-color 0.15s, background 0.15s', background: dropActive ? 'rgba(99,102,241,0.04)' : 'transparent' }}>
|
||||
<div style={{ width: 48, height: 48, borderRadius: '12px', background: dropActive ? '#6366f1' : '#e2e8f0', display: 'flex', alignItems: 'center', justifyContent: 'center', marginBottom: '1rem', transition: 'background 0.15s' }}>
|
||||
<Plus size={22} color={dropActive ? '#fff' : '#94a3b8'} />
|
||||
</div>
|
||||
<p style={{ color: '#64748b', fontSize: '0.9rem', fontWeight: 500, margin: 0 }}>
|
||||
{dropActive ? 'Release to add block' : 'Drop blocks here or click from the sidebar'}
|
||||
</p>
|
||||
<p style={{ color: '#94a3b8', fontSize: '0.8rem', marginTop: '0.375rem' }}>
|
||||
Start building your page by selecting blocks on the left
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ minHeight: '400px', border: dropActive ? '2px dashed #6366f1' : '2px dashed transparent', borderRadius: '6px', padding: dropActive ? '0.5rem' : '0', transition: 'all 0.15s', background: dropActive ? 'rgba(99,102,241,0.03)' : 'transparent' }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
|
||||
{blocks.map((block, idx) => (
|
||||
<CanvasItem
|
||||
key={block.instanceId}
|
||||
block={block}
|
||||
selected={selectedId === block.instanceId}
|
||||
isFirst={idx === 0}
|
||||
isLast={idx === blocks.length - 1}
|
||||
theme={theme}
|
||||
customBlocks={customBlocks}
|
||||
selectedId={selectedId}
|
||||
dataCache={dataCache}
|
||||
onSelect={onSelect}
|
||||
onRemove={onRemove}
|
||||
onMove={onMove}
|
||||
onDrop={onDrop}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{dropActive && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '1rem', color: '#6366f1', fontSize: '0.875rem', fontWeight: 500, borderTop: '1px dashed #c7d2fe', marginTop: '0.5rem' }}>
|
||||
<Plus size={16} style={{ marginRight: '0.375rem' }} />Drop to add at end
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
229
Frontend/src/app/components/ConsentManager.tsx
Normal file
229
Frontend/src/app/components/ConsentManager.tsx
Normal file
@ -0,0 +1,229 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { X } from 'lucide-react'
|
||||
|
||||
import {
|
||||
DEFAULT_CONSENT,
|
||||
acceptAllConsent,
|
||||
readConsentPreferences,
|
||||
rejectNonEssentialConsent,
|
||||
saveConsentPreferences,
|
||||
withDecisionTimestamp,
|
||||
type ConsentPreferences,
|
||||
} from '../consent'
|
||||
|
||||
function CategoryToggle({
|
||||
label,
|
||||
description,
|
||||
checked,
|
||||
disabled,
|
||||
onChange,
|
||||
}: {
|
||||
label: string
|
||||
description: string
|
||||
checked: boolean
|
||||
disabled?: boolean
|
||||
onChange: (checked: boolean) => void
|
||||
}) {
|
||||
return (
|
||||
<label className="flex items-start justify-between gap-3 rounded-lg border border-zinc-200 bg-white px-3 py-2.5">
|
||||
<span className="min-w-0">
|
||||
<span className="block text-xs font-semibold text-zinc-800">{label}</span>
|
||||
<span className="mt-0.5 block text-[11px] leading-4 text-zinc-500">{description}</span>
|
||||
</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
disabled={disabled}
|
||||
onChange={event => onChange(event.target.checked)}
|
||||
className="mt-0.5 h-4 w-4 rounded border-zinc-300 text-indigo-600 focus:ring-indigo-500 disabled:opacity-60"
|
||||
/>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
export function ConsentManager() {
|
||||
const [savedConsent, setSavedConsent] = useState<ConsentPreferences | null>(() => readConsentPreferences())
|
||||
const [editingConsent, setEditingConsent] = useState<ConsentPreferences>(() => savedConsent ?? DEFAULT_CONSENT)
|
||||
const [showPreferences, setShowPreferences] = useState(false)
|
||||
const [showReopenShortcut, setShowReopenShortcut] = useState(true)
|
||||
|
||||
const needsDecision = savedConsent === null
|
||||
|
||||
const consentSummary = useMemo(() => {
|
||||
if (!savedConsent) {
|
||||
return 'No consent choice saved yet.'
|
||||
}
|
||||
|
||||
const enabled = [
|
||||
savedConsent.analytics ? 'analytics' : null,
|
||||
savedConsent.marketing ? 'marketing' : null,
|
||||
savedConsent.preferences ? 'preferences' : null,
|
||||
].filter(Boolean)
|
||||
|
||||
if (enabled.length === 0) {
|
||||
return 'Only necessary cookies enabled.'
|
||||
}
|
||||
|
||||
return `Enabled: necessary, ${enabled.join(', ')}.`
|
||||
}, [savedConsent])
|
||||
|
||||
const persist = (next: ConsentPreferences) => {
|
||||
saveConsentPreferences(next)
|
||||
setSavedConsent(next)
|
||||
setEditingConsent(next)
|
||||
setShowPreferences(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{needsDecision && (
|
||||
<div data-testid="consent-banner" className="fixed inset-x-0 bottom-0 z-[100] border-t border-zinc-200 bg-white/98 px-4 py-3 shadow-[0_-10px_30px_rgba(0,0,0,0.08)] backdrop-blur">
|
||||
<div className="mx-auto flex w-full max-w-7xl flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
<div className="max-w-3xl">
|
||||
<p className="text-sm font-semibold text-zinc-900">Cookie Preferences</p>
|
||||
<p className="mt-1 text-xs text-zinc-600">
|
||||
We use necessary cookies to run the platform. You can accept all, reject non-essential cookies, or customize preferences.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
data-testid="consent-customize"
|
||||
onClick={() => {
|
||||
setEditingConsent(savedConsent ?? DEFAULT_CONSENT)
|
||||
setShowPreferences(true)
|
||||
}}
|
||||
className="rounded-md border border-zinc-300 px-3 py-1.5 text-xs font-semibold text-zinc-700 transition-colors hover:bg-zinc-100"
|
||||
>
|
||||
Customize
|
||||
</button>
|
||||
<button
|
||||
data-testid="consent-reject-non-essential"
|
||||
onClick={() => persist(rejectNonEssentialConsent())}
|
||||
className="rounded-md border border-zinc-300 px-3 py-1.5 text-xs font-semibold text-zinc-700 transition-colors hover:bg-zinc-100"
|
||||
>
|
||||
Reject Non-essential
|
||||
</button>
|
||||
<button
|
||||
data-testid="consent-accept-all"
|
||||
onClick={() => persist(acceptAllConsent())}
|
||||
className="rounded-md bg-zinc-900 px-3 py-1.5 text-xs font-semibold text-white transition-colors hover:bg-zinc-800"
|
||||
>
|
||||
Accept All
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!needsDecision && showReopenShortcut && (
|
||||
<div className="fixed bottom-4 left-4 z-[90] flex items-center gap-1.5">
|
||||
<button
|
||||
data-testid="consent-reopen-preferences"
|
||||
onClick={() => {
|
||||
setEditingConsent(savedConsent)
|
||||
setShowPreferences(true)
|
||||
}}
|
||||
className="rounded-full border border-zinc-300 bg-white px-3 py-1.5 text-[11px] font-semibold text-zinc-700 shadow-sm transition-colors hover:bg-zinc-100"
|
||||
title={consentSummary}
|
||||
>
|
||||
Cookie Preferences
|
||||
</button>
|
||||
<button
|
||||
data-testid="consent-dismiss-reopen-preferences"
|
||||
onClick={() => setShowReopenShortcut(false)}
|
||||
className="inline-flex h-7 w-7 items-center justify-center rounded-full border border-zinc-300 bg-white text-zinc-500 shadow-sm transition-colors hover:bg-zinc-100 hover:text-zinc-700"
|
||||
aria-label="Dismiss cookie preferences shortcut"
|
||||
title="Dismiss"
|
||||
>
|
||||
<X size={11} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showPreferences && (
|
||||
<div
|
||||
data-testid="consent-modal"
|
||||
className="fixed inset-0 z-[110] flex items-center justify-center bg-black/40 p-4"
|
||||
onClick={() => setShowPreferences(false)}
|
||||
>
|
||||
<div
|
||||
className="w-full max-w-2xl rounded-xl border border-zinc-200 bg-zinc-50 shadow-2xl"
|
||||
onClick={event => event.stopPropagation()}
|
||||
>
|
||||
<div className="border-b border-zinc-200 bg-white px-5 py-4">
|
||||
<h2 className="text-sm font-semibold text-zinc-900">Consent Preferences</h2>
|
||||
<p className="mt-1 text-xs text-zinc-600">Manage which optional cookie categories are enabled for this browser.</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 p-4">
|
||||
<CategoryToggle
|
||||
label="Necessary"
|
||||
description="Required for login, security, and essential application functionality."
|
||||
checked={true}
|
||||
disabled
|
||||
onChange={() => {}}
|
||||
/>
|
||||
<CategoryToggle
|
||||
label="Analytics"
|
||||
description="Allows anonymized usage measurement to improve product experience."
|
||||
checked={editingConsent.analytics}
|
||||
onChange={checked => setEditingConsent(prev => ({ ...prev, analytics: checked }))}
|
||||
/>
|
||||
<CategoryToggle
|
||||
label="Marketing"
|
||||
description="Allows campaign attribution and marketing optimization features."
|
||||
checked={editingConsent.marketing}
|
||||
onChange={checked => setEditingConsent(prev => ({ ...prev, marketing: checked }))}
|
||||
/>
|
||||
<CategoryToggle
|
||||
label="Preferences"
|
||||
description="Saves personalization options such as UI preferences and defaults."
|
||||
checked={editingConsent.preferences}
|
||||
onChange={checked => setEditingConsent(prev => ({ ...prev, preferences: checked }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 border-t border-zinc-200 bg-white px-5 py-3">
|
||||
<p className="text-[11px] text-zinc-500">{consentSummary}</p>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
data-testid="consent-modal-cancel"
|
||||
onClick={() => setShowPreferences(false)}
|
||||
className="rounded-md border border-zinc-300 px-3 py-1.5 text-xs font-semibold text-zinc-700 hover:bg-zinc-100"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
data-testid="consent-modal-reject-non-essential"
|
||||
onClick={() => persist(rejectNonEssentialConsent())}
|
||||
className="rounded-md border border-zinc-300 px-3 py-1.5 text-xs font-semibold text-zinc-700 hover:bg-zinc-100"
|
||||
>
|
||||
Reject Non-essential
|
||||
</button>
|
||||
<button
|
||||
data-testid="consent-modal-accept-all"
|
||||
onClick={() => persist(acceptAllConsent())}
|
||||
className="rounded-md border border-zinc-800 px-3 py-1.5 text-xs font-semibold text-zinc-900 hover:bg-zinc-100"
|
||||
>
|
||||
Accept All
|
||||
</button>
|
||||
<button
|
||||
data-testid="consent-modal-save-preferences"
|
||||
onClick={() => persist(withDecisionTimestamp({
|
||||
necessary: true,
|
||||
analytics: editingConsent.analytics,
|
||||
marketing: editingConsent.marketing,
|
||||
preferences: editingConsent.preferences,
|
||||
}))}
|
||||
className="rounded-md bg-indigo-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-indigo-500"
|
||||
>
|
||||
Save Preferences
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
20
Frontend/src/app/components/CustomBlockRenderer.tsx
Normal file
20
Frontend/src/app/components/CustomBlockRenderer.tsx
Normal file
@ -0,0 +1,20 @@
|
||||
import type { ThemeConfig } from '../App'
|
||||
|
||||
export function CustomBlockRenderer({ code: _code, theme }: { code: string; theme: ThemeConfig }) {
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
style={{
|
||||
border: `1px dashed ${theme.border}`,
|
||||
background: theme.muted,
|
||||
color: theme.mutedForeground,
|
||||
borderRadius: `${theme.radius}rem`,
|
||||
padding: '1rem',
|
||||
fontFamily: theme.fontFamily,
|
||||
fontSize: '0.8rem',
|
||||
}}
|
||||
>
|
||||
Custom component execution is disabled until an isolated sandbox runtime is configured.
|
||||
</div>
|
||||
)
|
||||
}
|
||||
379
Frontend/src/app/components/DataSourceManager.tsx
Normal file
379
Frontend/src/app/components/DataSourceManager.tsx
Normal file
@ -0,0 +1,379 @@
|
||||
import { useState, useCallback, useEffect } from 'react'
|
||||
import {
|
||||
X, Plus, Trash2, Database, Globe, FileJson, Play, ChevronRight,
|
||||
CheckCircle2, XCircle, Loader2, Eye, EyeOff, RefreshCw,
|
||||
} from 'lucide-react'
|
||||
import type { DataSource, DataCacheEntry } from '../App'
|
||||
|
||||
const EMPTY: DataSource = {
|
||||
id: '', name: '', type: 'rest', url: '', method: 'GET',
|
||||
headers: '{}', body: '', staticData: '[\n {\n "id": 1,\n "title": "Example item",\n "description": "A sample data entry"\n }\n]',
|
||||
path: '',
|
||||
}
|
||||
|
||||
function getFieldsFromData(data: Record<string, unknown>[]): string[] {
|
||||
if (!data.length) return []
|
||||
return Object.keys(data[0])
|
||||
}
|
||||
|
||||
function resolvePath(obj: unknown, path: string): unknown {
|
||||
if (!path.trim()) return obj
|
||||
return path.split('.').reduce((acc: unknown, key) => {
|
||||
if (acc && typeof acc === 'object') return (acc as Record<string, unknown>)[key]
|
||||
return undefined
|
||||
}, obj)
|
||||
}
|
||||
|
||||
async function fetchSource(source: DataSource): Promise<{ data: Record<string, unknown>[]; error: string | null }> {
|
||||
if (source.type === 'static') {
|
||||
try {
|
||||
const parsed = JSON.parse(source.staticData || '[]')
|
||||
const arr = Array.isArray(parsed) ? parsed : [parsed]
|
||||
return { data: arr as Record<string, unknown>[], error: null }
|
||||
} catch {
|
||||
return { data: [], error: 'Invalid JSON' }
|
||||
}
|
||||
}
|
||||
try {
|
||||
const hdrs: Record<string, string> = { '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 ? resolvePath(json, source.path) : json
|
||||
const arr = Array.isArray(resolved) ? resolved : (resolved ? [resolved] : [])
|
||||
return { data: arr as Record<string, unknown>[], error: null }
|
||||
} catch (e) {
|
||||
return { data: [], error: String(e) }
|
||||
}
|
||||
}
|
||||
|
||||
// ── Example presets ────────────────────────────────────────────
|
||||
const PRESETS = [
|
||||
{
|
||||
label: 'JSONPlaceholder Posts',
|
||||
source: { ...EMPTY, name: 'Blog Posts', type: 'rest' as const, url: 'https://jsonplaceholder.typicode.com/posts', method: 'GET' as const, path: '' },
|
||||
},
|
||||
{
|
||||
label: 'JSONPlaceholder Users',
|
||||
source: { ...EMPTY, name: 'Users', type: 'rest' as const, url: 'https://jsonplaceholder.typicode.com/users', method: 'GET' as const, path: '' },
|
||||
},
|
||||
{
|
||||
label: 'Open Library Search',
|
||||
source: { ...EMPTY, name: 'Books', type: 'rest' as const, url: 'https://openlibrary.org/search.json?q=javascript&limit=10', method: 'GET' as const, path: 'docs' },
|
||||
},
|
||||
{
|
||||
label: 'Static JSON',
|
||||
source: { ...EMPTY, name: 'My Data', type: 'static' as const },
|
||||
},
|
||||
]
|
||||
|
||||
export function DataSourceManager({
|
||||
dataSources,
|
||||
dataCache,
|
||||
onSave,
|
||||
onDelete,
|
||||
onRefresh,
|
||||
onClose,
|
||||
}: {
|
||||
dataSources: DataSource[]
|
||||
dataCache: Record<string, DataCacheEntry>
|
||||
onSave: (source: DataSource) => void
|
||||
onDelete: (id: string) => void
|
||||
onRefresh: (id: string) => void
|
||||
onClose: () => void
|
||||
}) {
|
||||
const [editing, setEditing] = useState<DataSource | null>(null)
|
||||
const [testResult, setTestResult] = useState<{ data: Record<string, unknown>[]; error: string | null } | null>(null)
|
||||
const [testing, setTesting] = useState(false)
|
||||
const [showPreview, setShowPreview] = useState(false)
|
||||
const [confirmDelete, setConfirmDelete] = useState<string | null>(null)
|
||||
|
||||
const startNew = (preset?: DataSource) => {
|
||||
const id = `ds-${Date.now()}`
|
||||
setEditing({ ...(preset ?? EMPTY), id })
|
||||
setTestResult(null)
|
||||
setShowPreview(false)
|
||||
}
|
||||
|
||||
const handleTest = useCallback(async () => {
|
||||
if (!editing) return
|
||||
setTesting(true)
|
||||
setTestResult(null)
|
||||
const result = await fetchSource(editing)
|
||||
setTestResult(result)
|
||||
setTesting(false)
|
||||
setShowPreview(true)
|
||||
}, [editing])
|
||||
|
||||
const handleSave = () => {
|
||||
if (!editing || !editing.name.trim()) return
|
||||
onSave(editing)
|
||||
setEditing(null)
|
||||
setTestResult(null)
|
||||
}
|
||||
|
||||
const fields = testResult?.data ? getFieldsFromData(testResult.data) : []
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{ position: 'fixed', inset: 0, zIndex: 60, display: 'flex', alignItems: 'stretch', background: 'rgba(0,0,0,0.45)', backdropFilter: 'blur(4px)' }}
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
style={{ display: 'flex', width: '100%', height: '100%', maxWidth: 940, margin: 'auto', background: '#fff', borderRadius: '16px', overflow: 'hidden', boxShadow: '0 24px 80px rgba(0,0,0,0.25)', maxHeight: '88vh' }}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
{/* Source list */}
|
||||
<div style={{ width: 260, flexShrink: 0, borderRight: '1px solid #f1f5f9', display: 'flex', flexDirection: 'column', background: '#fafafa' }}>
|
||||
<div style={{ padding: '1rem', borderBottom: '1px solid #f1f5f9', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<div>
|
||||
<p style={{ margin: 0, fontSize: '0.8rem', fontWeight: 700, color: '#0f172a' }}>Data Sources</p>
|
||||
<p style={{ margin: '2px 0 0', fontSize: '0.7rem', color: '#94a3b8' }}>{dataSources.length} configured</p>
|
||||
</div>
|
||||
<button onClick={() => startNew()} style={{ display: 'flex', alignItems: 'center', gap: '0.25rem', padding: '0.3rem 0.625rem', background: '#6366f1', color: '#fff', border: 'none', borderRadius: '6px', cursor: 'pointer', fontSize: '0.72rem', fontWeight: 600 }}>
|
||||
<Plus size={11} />New
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Presets */}
|
||||
{dataSources.length === 0 && !editing && (
|
||||
<div style={{ padding: '0.75rem', borderBottom: '1px solid #f1f5f9' }}>
|
||||
<p style={{ margin: '0 0 0.5rem', fontSize: '0.7rem', fontWeight: 600, color: '#94a3b8', textTransform: 'uppercase', letterSpacing: '0.06em' }}>Quick start</p>
|
||||
{PRESETS.map(p => (
|
||||
<button key={p.label} onClick={() => startNew(p.source)}
|
||||
style={{ width: '100%', textAlign: 'left', padding: '0.4rem 0.5rem', border: 'none', background: 'transparent', cursor: 'pointer', fontSize: '0.75rem', color: '#475569', borderRadius: '5px', display: 'flex', alignItems: 'center', gap: '0.375rem' }}
|
||||
onMouseEnter={e => (e.currentTarget.style.background = '#f1f5f9')}
|
||||
onMouseLeave={e => (e.currentTarget.style.background = 'transparent')}
|
||||
>
|
||||
{p.source.type === 'static' ? <FileJson size={11} /> : <Globe size={11} />}
|
||||
{p.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ flex: 1, overflowY: 'auto', padding: '0.5rem' }}>
|
||||
{dataSources.length === 0 && (
|
||||
<div style={{ padding: '1.5rem 0.5rem', textAlign: 'center', color: '#94a3b8', fontSize: '0.75rem' }}>
|
||||
<Database size={24} style={{ opacity: 0.3, marginBottom: '0.5rem' }} />
|
||||
<p style={{ margin: 0 }}>No data sources yet</p>
|
||||
</div>
|
||||
)}
|
||||
{dataSources.map(source => {
|
||||
const cache = dataCache[source.id]
|
||||
const isEditing = editing?.id === source.id
|
||||
return (
|
||||
<div
|
||||
key={source.id}
|
||||
onClick={() => { setEditing({ ...source }); setTestResult(null); setShowPreview(false) }}
|
||||
style={{
|
||||
padding: '0.625rem 0.75rem', borderRadius: '8px', cursor: 'pointer',
|
||||
border: isEditing ? '2px solid #6366f1' : '2px solid transparent',
|
||||
background: isEditing ? '#f5f3ff' : 'transparent', marginBottom: '2px',
|
||||
transition: 'all 0.1s',
|
||||
}}
|
||||
onMouseEnter={e => { if (!isEditing) (e.currentTarget as HTMLDivElement).style.background = '#f1f5f9' }}
|
||||
onMouseLeave={e => { if (!isEditing) (e.currentTarget as HTMLDivElement).style.background = 'transparent' }}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.375rem', marginBottom: '2px' }}>
|
||||
{source.type === 'static' ? <FileJson size={11} style={{ color: '#6366f1' }} /> : <Globe size={11} style={{ color: '#0ea5e9' }} />}
|
||||
<span style={{ fontSize: '0.8rem', fontWeight: 600, color: '#0f172a', flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{source.name}</span>
|
||||
{cache?.loading && <Loader2 size={10} style={{ color: '#6366f1', animation: 'spin 1s linear infinite' }} />}
|
||||
{!cache?.loading && cache?.error && <XCircle size={10} style={{ color: '#ef4444' }} />}
|
||||
{!cache?.loading && !cache?.error && cache?.data?.length > 0 && <CheckCircle2 size={10} style={{ color: '#22c55e' }} />}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '0.5rem' }}>
|
||||
<span style={{ fontSize: '0.65rem', color: '#94a3b8' }}>
|
||||
{source.type === 'static' ? 'Static' : `${source.method} · ${source.url.split('/').slice(0, 4).join('/')}`}
|
||||
</span>
|
||||
{cache?.data && <span style={{ fontSize: '0.65rem', color: '#64748b', fontWeight: 600 }}>{cache.data.length} items</span>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Edit pane */}
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
||||
{/* Header */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '0.875rem 1.25rem', borderBottom: '1px solid #f1f5f9', flexShrink: 0 }}>
|
||||
<span style={{ fontSize: '0.875rem', fontWeight: 700, color: '#0f172a' }}>
|
||||
{editing ? (editing.name || 'New data source') : 'Data Sources'}
|
||||
</span>
|
||||
<button onClick={onClose} style={{ width: 30, height: 30, display: 'flex', alignItems: 'center', justifyContent: 'center', border: 'none', background: '#f1f5f9', borderRadius: '7px', cursor: 'pointer', color: '#64748b' }}>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{editing ? (
|
||||
<div style={{ flex: 1, overflowY: 'auto', display: 'flex', flexDirection: 'column' }}>
|
||||
{/* Form */}
|
||||
<div style={{ padding: '1rem 1.25rem', borderBottom: '1px solid #f1f5f9' }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0.75rem' }}>
|
||||
<div style={{ gridColumn: 'span 2' }}>
|
||||
<label style={lbl}>Name</label>
|
||||
<input style={inp} value={editing.name} onChange={e => setEditing(s => s ? { ...s, name: e.target.value } : s)} placeholder="My API" />
|
||||
</div>
|
||||
<div>
|
||||
<label style={lbl}>Type</label>
|
||||
<select style={inp} value={editing.type} onChange={e => setEditing(s => s ? { ...s, type: e.target.value as 'rest' | 'static' } : s)}>
|
||||
<option value="rest">REST API</option>
|
||||
<option value="static">Static JSON</option>
|
||||
</select>
|
||||
</div>
|
||||
{editing.type === 'rest' && (
|
||||
<div>
|
||||
<label style={lbl}>Method</label>
|
||||
<select style={inp} value={editing.method} onChange={e => setEditing(s => s ? { ...s, method: e.target.value as 'GET' | 'POST' } : s)}>
|
||||
<option>GET</option>
|
||||
<option>POST</option>
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
{editing.type === 'rest' && (
|
||||
<div style={{ gridColumn: 'span 2' }}>
|
||||
<label style={lbl}>URL</label>
|
||||
<input style={inp} value={editing.url} onChange={e => setEditing(s => s ? { ...s, url: e.target.value } : s)} placeholder="https://api.example.com/items" />
|
||||
</div>
|
||||
)}
|
||||
{editing.type === 'rest' && (
|
||||
<div>
|
||||
<label style={lbl}>Response path <span style={{ color: '#94a3b8' }}>(optional)</span></label>
|
||||
<input style={inp} value={editing.path} onChange={e => setEditing(s => s ? { ...s, path: e.target.value } : s)} placeholder="data.results" />
|
||||
</div>
|
||||
)}
|
||||
{editing.type === 'rest' && (
|
||||
<div>
|
||||
<label style={lbl}>Headers <span style={{ color: '#94a3b8' }}>(JSON)</span></label>
|
||||
<input style={inp} value={editing.headers} onChange={e => setEditing(s => s ? { ...s, headers: e.target.value } : s)} placeholder='{"Authorization": "Bearer ..."}' />
|
||||
</div>
|
||||
)}
|
||||
{editing.type === 'rest' && editing.method === 'POST' && (
|
||||
<div style={{ gridColumn: 'span 2' }}>
|
||||
<label style={lbl}>Request body</label>
|
||||
<textarea style={{ ...inp, height: 64, resize: 'none' as const }} value={editing.body} onChange={e => setEditing(s => s ? { ...s, body: e.target.value } : s)} placeholder='{"query": "..."}' />
|
||||
</div>
|
||||
)}
|
||||
{editing.type === 'static' && (
|
||||
<div style={{ gridColumn: 'span 2' }}>
|
||||
<label style={lbl}>JSON data <span style={{ color: '#94a3b8' }}>(array)</span></label>
|
||||
<textarea
|
||||
style={{ ...inp, height: 160, resize: 'vertical' as const, fontFamily: 'monospace', fontSize: '0.75rem' }}
|
||||
value={editing.staticData}
|
||||
onChange={e => setEditing(s => s ? { ...s, staticData: e.target.value } : s)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div style={{ display: 'flex', gap: '0.5rem', marginTop: '0.875rem', flexWrap: 'wrap' }}>
|
||||
<button onClick={handleTest} disabled={testing} style={{ display: 'flex', alignItems: 'center', gap: '0.375rem', padding: '0.4rem 0.875rem', background: '#f1f5f9', border: '1px solid #e2e8f0', borderRadius: '7px', cursor: 'pointer', fontSize: '0.8rem', fontWeight: 600, color: '#475569' }}>
|
||||
{testing ? <Loader2 size={12} style={{ animation: 'spin 1s linear infinite' }} /> : <Play size={12} />}
|
||||
Test
|
||||
</button>
|
||||
<button onClick={handleSave} disabled={!editing.name.trim()} style={{ display: 'flex', alignItems: 'center', gap: '0.375rem', padding: '0.4rem 0.875rem', background: '#6366f1', color: '#fff', border: 'none', borderRadius: '7px', cursor: 'pointer', fontSize: '0.8rem', fontWeight: 600, opacity: editing.name.trim() ? 1 : 0.5 }}>
|
||||
Save source
|
||||
</button>
|
||||
{dataSources.find(s => s.id === editing.id) && (
|
||||
confirmDelete === editing.id ? (
|
||||
<>
|
||||
<span style={{ fontSize: '0.75rem', color: '#ef4444', display: 'flex', alignItems: 'center' }}>Delete?</span>
|
||||
<button onClick={() => { onDelete(editing.id); setEditing(null); setConfirmDelete(null) }} style={{ padding: '0.4rem 0.625rem', background: '#ef4444', color: '#fff', border: 'none', borderRadius: '7px', cursor: 'pointer', fontSize: '0.75rem', fontWeight: 600 }}>Yes</button>
|
||||
<button onClick={() => setConfirmDelete(null)} style={{ padding: '0.4rem 0.625rem', background: '#f1f5f9', color: '#64748b', border: 'none', borderRadius: '7px', cursor: 'pointer', fontSize: '0.75rem' }}>No</button>
|
||||
</>
|
||||
) : (
|
||||
<button onClick={() => setConfirmDelete(editing.id)} style={{ display: 'flex', alignItems: 'center', gap: '0.25rem', padding: '0.4rem 0.625rem', background: 'transparent', border: '1px solid #fecaca', color: '#ef4444', borderRadius: '7px', cursor: 'pointer', fontSize: '0.75rem' }}>
|
||||
<Trash2 size={11} />Delete
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
<button onClick={() => { setEditing(null); setTestResult(null) }} style={{ marginLeft: 'auto', padding: '0.4rem 0.625rem', background: 'transparent', border: 'none', color: '#94a3b8', cursor: 'pointer', fontSize: '0.75rem' }}>Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Test result */}
|
||||
{testResult && (
|
||||
<div style={{ flex: 1, overflowY: 'auto' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', padding: '0.625rem 1.25rem', borderBottom: '1px solid #f1f5f9', background: testResult.error ? '#fef2f2' : '#f0fdf4', flexShrink: 0 }}>
|
||||
{testResult.error
|
||||
? <><XCircle size={14} style={{ color: '#ef4444' }} /><span style={{ fontSize: '0.75rem', color: '#dc2626', fontWeight: 600 }}>Error: {testResult.error}</span></>
|
||||
: <><CheckCircle2 size={14} style={{ color: '#22c55e' }} /><span style={{ fontSize: '0.75rem', color: '#15803d', fontWeight: 600 }}>{testResult.data.length} items returned</span>
|
||||
{fields.length > 0 && <span style={{ fontSize: '0.7rem', color: '#64748b', marginLeft: '0.5rem' }}>Fields: {fields.join(', ')}</span>}
|
||||
</>
|
||||
}
|
||||
{!testResult.error && (
|
||||
<button onClick={() => setShowPreview(p => !p)} style={{ marginLeft: 'auto', display: 'flex', alignItems: 'center', gap: '0.25rem', padding: '0.2rem 0.5rem', background: 'transparent', border: '1px solid #e2e8f0', borderRadius: '5px', cursor: 'pointer', fontSize: '0.7rem', color: '#64748b' }}>
|
||||
{showPreview ? <EyeOff size={10} /> : <Eye size={10} />}{showPreview ? 'Hide' : 'Preview'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{showPreview && !testResult.error && testResult.data.length > 0 && (
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.72rem' }}>
|
||||
<thead>
|
||||
<tr style={{ background: '#f8fafc' }}>
|
||||
{fields.slice(0, 8).map(f => (
|
||||
<th key={f} style={{ padding: '0.5rem 0.75rem', textAlign: 'left', fontWeight: 700, color: '#475569', borderBottom: '1px solid #f1f5f9', whiteSpace: 'nowrap' }}>{f}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{testResult.data.slice(0, 8).map((row, i) => (
|
||||
<tr key={i} style={{ borderBottom: '1px solid #f8fafc' }}>
|
||||
{fields.slice(0, 8).map(f => (
|
||||
<td key={f} style={{ padding: '0.4rem 0.75rem', color: '#334155', maxWidth: 180, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{String(row[f] ?? '')}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{testResult.data.length > 8 && <p style={{ padding: '0.5rem 0.75rem', margin: 0, fontSize: '0.7rem', color: '#94a3b8' }}>… and {testResult.data.length - 8} more rows</p>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', color: '#94a3b8', gap: '0.5rem' }}>
|
||||
<Database size={36} style={{ opacity: 0.25 }} />
|
||||
<p style={{ margin: 0, fontSize: '0.875rem', fontWeight: 500 }}>Select a source to edit</p>
|
||||
<p style={{ margin: 0, fontSize: '0.8rem' }}>or <button onClick={() => startNew()} style={{ background: 'none', border: 'none', color: '#6366f1', cursor: 'pointer', fontWeight: 600, padding: 0, fontSize: '0.8rem' }}>create a new one</button></p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Live cache overview when no editing */}
|
||||
{!editing && dataSources.length > 0 && (
|
||||
<div style={{ padding: '0.75rem 1.25rem', borderTop: '1px solid #f1f5f9', display: 'flex', flexWrap: 'wrap', gap: '0.5rem' }}>
|
||||
{dataSources.map(s => {
|
||||
const cache = dataCache[s.id]
|
||||
return (
|
||||
<div key={s.id} style={{ display: 'flex', alignItems: 'center', gap: '0.375rem', padding: '0.25rem 0.625rem', background: '#f8fafc', border: '1px solid #e2e8f0', borderRadius: '6px', fontSize: '0.72rem', color: '#475569' }}>
|
||||
<span style={{ fontWeight: 600 }}>{s.name}</span>
|
||||
{cache?.loading && <Loader2 size={9} style={{ color: '#6366f1' }} />}
|
||||
{!cache?.loading && cache?.error && <span style={{ color: '#ef4444' }}>error</span>}
|
||||
{!cache?.loading && !cache?.error && <span style={{ color: '#22c55e' }}>{cache?.data?.length ?? 0} items</span>}
|
||||
<button onClick={() => onRefresh(s.id)} title="Refresh" style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 0, color: '#94a3b8' }}>
|
||||
<RefreshCw size={9} />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const lbl: React.CSSProperties = { display: 'block', fontSize: '0.72rem', fontWeight: 600, color: '#475569', marginBottom: '0.25rem' }
|
||||
const inp: React.CSSProperties = { width: '100%', fontSize: '0.78rem', padding: '0.4rem 0.625rem', border: '1px solid #e2e8f0', borderRadius: '6px', outline: 'none', background: '#fff', color: '#0f172a', boxSizing: 'border-box', fontFamily: 'inherit' }
|
||||
450
Frontend/src/app/components/McpPanel.tsx
Normal file
450
Frontend/src/app/components/McpPanel.tsx
Normal file
@ -0,0 +1,450 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||
import { X, Zap, Wrench, Webhook as WebhookIcon, ChevronRight, Play, Copy, Check, Plus, Trash2, ToggleLeft, ToggleRight, Circle, AlertCircle, CheckCircle2 } from 'lucide-react'
|
||||
import { EventBus, MCP_EVENTS, type MCPEventEntry, type MCPEventName } from '../mcp/EventBus'
|
||||
import { TOOLS, callTool } from '../mcp/tools'
|
||||
import { loadWebhooks, saveWebhooks, createWebhook, type Webhook } from '../mcp/WebhookStore'
|
||||
|
||||
// ── Event Log ─────────────────────────────────────────────────
|
||||
const EVENT_COLORS: Record<string, string> = {
|
||||
cms: '#6366f1',
|
||||
scorm: '#8b5cf6',
|
||||
admin: '#10b981',
|
||||
}
|
||||
function categoryOf(event: string) {
|
||||
return event.startsWith('cms:') ? 'cms' : event.startsWith('scorm:') ? 'scorm' : 'admin'
|
||||
}
|
||||
|
||||
function EventLogTab() {
|
||||
const [entries, setEntries] = useState<MCPEventEntry[]>(() => EventBus.log)
|
||||
const [expanded, setExpanded] = useState<Set<string>>(new Set())
|
||||
const [filter, setFilter] = useState<string>('all')
|
||||
const [copied, setCopied] = useState<string | null>(null)
|
||||
const bottomRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const unsub = EventBus.onLog(entry => {
|
||||
setEntries(prev => [entry, ...prev].slice(0, 200))
|
||||
})
|
||||
return unsub
|
||||
}, [])
|
||||
|
||||
function toggleExpand(id: string) {
|
||||
setExpanded(prev => { const n = new Set(prev); n.has(id) ? n.delete(id) : n.add(id); return n })
|
||||
}
|
||||
|
||||
async function copyEntry(entry: MCPEventEntry) {
|
||||
const text = JSON.stringify({ event: entry.event, payload: entry.payload }, null, 2)
|
||||
await navigator.clipboard.writeText(text)
|
||||
setCopied(entry.id); setTimeout(() => setCopied(null), 1500)
|
||||
}
|
||||
|
||||
const FILTER_OPTS = ['all', 'cms', 'scorm', 'admin']
|
||||
const filtered = filter === 'all' ? entries : entries.filter(e => categoryOf(e.event) === filter)
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex items-center gap-2 p-3 border-b border-zinc-800 flex-shrink-0">
|
||||
<div className="flex gap-1">
|
||||
{FILTER_OPTS.map(f => (
|
||||
<button key={f} onClick={() => setFilter(f)}
|
||||
className={`px-2 py-0.5 rounded text-xs font-semibold uppercase tracking-wide transition-colors ${filter === f ? 'bg-indigo-500 text-white' : 'text-zinc-500 hover:text-zinc-300'}`}>
|
||||
{f}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<span className="ml-auto text-xs text-zinc-600">{filtered.length} events</span>
|
||||
<button onClick={() => setEntries([])} className="text-xs text-zinc-600 hover:text-red-400 transition-colors">clear</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{filtered.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center h-32 text-zinc-700">
|
||||
<Zap size={20} className="mb-2 opacity-40" />
|
||||
<p className="text-xs">No events yet</p>
|
||||
<p className="text-xs mt-0.5 opacity-60">Events fire as you use the builder</p>
|
||||
</div>
|
||||
)}
|
||||
{filtered.map(entry => {
|
||||
const cat = categoryOf(entry.event)
|
||||
const color = EVENT_COLORS[cat] ?? '#6366f1'
|
||||
const isOpen = expanded.has(entry.id)
|
||||
const time = new Date(entry.timestamp)
|
||||
return (
|
||||
<div key={entry.id} className="border-b border-zinc-800/60">
|
||||
<div className="flex items-center gap-2 px-3 py-2 hover:bg-zinc-800/40 cursor-pointer select-none"
|
||||
onClick={() => toggleExpand(entry.id)}>
|
||||
<span className="w-1.5 h-1.5 rounded-full flex-shrink-0" style={{ background: color }} />
|
||||
<span className="text-xs font-mono text-zinc-300 flex-1 truncate">{entry.event}</span>
|
||||
<span className="text-[9px] text-zinc-600 flex-shrink-0 font-mono">
|
||||
{time.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' })}
|
||||
</span>
|
||||
<button onClick={e => { e.stopPropagation(); copyEntry(entry) }}
|
||||
className="text-zinc-600 hover:text-zinc-300 flex-shrink-0 transition-colors">
|
||||
{copied === entry.id ? <Check size={10} className="text-green-400" /> : <Copy size={10} />}
|
||||
</button>
|
||||
<ChevronRight size={10} className={`text-zinc-600 flex-shrink-0 transition-transform ${isOpen ? 'rotate-90' : ''}`} />
|
||||
</div>
|
||||
{isOpen && (
|
||||
<div className="px-3 pb-3">
|
||||
<pre className="text-xs font-mono text-zinc-400 bg-zinc-900 rounded p-2 overflow-x-auto whitespace-pre-wrap break-all leading-relaxed">
|
||||
{JSON.stringify(entry.payload, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<div ref={bottomRef} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Tools Tab ─────────────────────────────────────────────────
|
||||
function ToolsTab() {
|
||||
const [selected, setSelected] = useState<string | null>(null)
|
||||
const [args, setArgs] = useState<Record<string, string>>({})
|
||||
const [result, setResult] = useState<unknown>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [running, setRunning] = useState(false)
|
||||
const [catFilter, setCatFilter] = useState<'all' | 'cms' | 'scorm' | 'admin'>('all')
|
||||
|
||||
const tool = TOOLS.find(t => t.name === selected)
|
||||
|
||||
function selectTool(name: string) {
|
||||
setSelected(name)
|
||||
const t = TOOLS.find(t => t.name === name)
|
||||
setArgs(Object.fromEntries(t?.params.map(p => [p.name, '']) ?? []))
|
||||
setResult(null); setError(null)
|
||||
}
|
||||
|
||||
async function run() {
|
||||
if (!tool) return
|
||||
setRunning(true); setResult(null); setError(null)
|
||||
try {
|
||||
const parsedArgs: Record<string, unknown> = {}
|
||||
for (const p of tool.params) {
|
||||
const raw = args[p.name]
|
||||
if (!raw && !p.required) continue
|
||||
if (p.type === 'number') parsedArgs[p.name] = Number(raw)
|
||||
else if (p.type === 'boolean') parsedArgs[p.name] = raw === 'true'
|
||||
else if (p.type === 'object' || p.type === 'array') parsedArgs[p.name] = JSON.parse(raw)
|
||||
else parsedArgs[p.name] = raw
|
||||
}
|
||||
const res = await callTool(tool.name, parsedArgs)
|
||||
setResult(res)
|
||||
} catch (e) {
|
||||
setError(String(e))
|
||||
} finally {
|
||||
setRunning(false)
|
||||
}
|
||||
}
|
||||
|
||||
const CAT_COLORS: Record<string, string> = { cms: 'text-indigo-400', scorm: 'text-purple-400', admin: 'text-green-400' }
|
||||
const cats = ['all', 'cms', 'scorm', 'admin'] as const
|
||||
const filteredTools = catFilter === 'all' ? TOOLS : TOOLS.filter(t => t.category === catFilter)
|
||||
|
||||
return (
|
||||
<div className="flex h-full overflow-hidden">
|
||||
{/* Tool list */}
|
||||
<div className="w-48 flex-shrink-0 border-r border-zinc-800 overflow-y-auto">
|
||||
<div className="flex gap-0.5 p-2 flex-wrap">
|
||||
{cats.map(c => (
|
||||
<button key={c} onClick={() => setCatFilter(c)}
|
||||
className={`px-1.5 py-0.5 rounded text-[9px] font-semibold uppercase tracking-wide transition-colors ${catFilter === c ? 'bg-zinc-700 text-white' : 'text-zinc-600 hover:text-zinc-400'}`}>
|
||||
{c}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{filteredTools.map(t => (
|
||||
<button key={t.name} onClick={() => selectTool(t.name)}
|
||||
className={`w-full text-left px-3 py-2 text-xs font-mono transition-colors border-b border-zinc-800/60 ${selected === t.name ? 'bg-indigo-900/40 text-indigo-300' : 'text-zinc-500 hover:text-zinc-300 hover:bg-zinc-800/30'}`}>
|
||||
<span className={`mr-1 text-[9px] ${CAT_COLORS[t.category]}`}>●</span>
|
||||
{t.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Tool detail */}
|
||||
<div className="flex-1 overflow-y-auto p-3">
|
||||
{!tool ? (
|
||||
<div className="flex flex-col items-center justify-center h-32 text-zinc-700">
|
||||
<Wrench size={20} className="mb-2 opacity-40" />
|
||||
<p className="text-xs">Select a tool to run it</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<div className="text-xs font-mono text-indigo-300 mb-1">{tool.name}</div>
|
||||
<p className="text-xs text-zinc-500 leading-relaxed">{tool.description}</p>
|
||||
</div>
|
||||
{tool.params.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
{tool.params.map(p => (
|
||||
<div key={p.name}>
|
||||
<label className="text-xs text-zinc-500 block mb-1">
|
||||
<span className="font-mono text-zinc-400">{p.name}</span>
|
||||
{p.required && <span className="text-red-400 ml-1">*</span>}
|
||||
<span className="ml-2 text-zinc-700">({p.type})</span>
|
||||
{' '}{p.description}
|
||||
</label>
|
||||
<input value={args[p.name] ?? ''} onChange={e => setArgs(prev => ({ ...prev, [p.name]: e.target.value }))}
|
||||
placeholder={p.type === 'object' ? '{"key":"value"}' : p.type === 'array' ? '[...]' : p.description}
|
||||
className="w-full text-xs font-mono px-2 py-1.5 bg-zinc-900 border border-zinc-700 rounded text-zinc-300 outline-none focus:border-indigo-500 placeholder-zinc-700" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<button onClick={run} disabled={running}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium bg-indigo-600 hover:bg-indigo-700 disabled:opacity-50 text-white rounded-lg transition-colors">
|
||||
<Play size={11} />{running ? 'Running…' : 'Run'}
|
||||
</button>
|
||||
{error && (
|
||||
<div className="bg-red-900/30 border border-red-800 rounded p-2 text-xs font-mono text-red-300">{error}</div>
|
||||
)}
|
||||
{result !== null && (
|
||||
<div>
|
||||
<div className="text-xs text-zinc-600 mb-1">Result</div>
|
||||
<pre className="text-xs font-mono text-zinc-300 bg-zinc-900 rounded p-2 overflow-x-auto whitespace-pre-wrap break-all">
|
||||
{JSON.stringify(result, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Webhooks Tab ───────────────────────────────────────────────
|
||||
const ALL_EVENTS: MCPEventName[] = [...MCP_EVENTS]
|
||||
|
||||
function WebhooksTab() {
|
||||
const [webhooks, setWebhooks] = useState<Webhook[]>([])
|
||||
const [showAdd, setShowAdd] = useState(false)
|
||||
const [newName, setNewName] = useState('')
|
||||
const [newUrl, setNewUrl] = useState('')
|
||||
const [newEvents, setNewEvents] = useState<Set<MCPEventName>>(new Set())
|
||||
const [evFilter, setEvFilter] = useState('all')
|
||||
const [copied, setCopied] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true
|
||||
const bootstrap = async () => {
|
||||
try {
|
||||
const loaded = await loadWebhooks()
|
||||
if (mounted) setWebhooks(loaded)
|
||||
} catch (error) {
|
||||
console.error('failed to load webhooks', error)
|
||||
}
|
||||
}
|
||||
void bootstrap()
|
||||
return () => { mounted = false }
|
||||
}, [])
|
||||
|
||||
function save(next: Webhook[]) {
|
||||
setWebhooks(next)
|
||||
void saveWebhooks(next)
|
||||
}
|
||||
|
||||
function addWebhook() {
|
||||
if (!newName.trim() || !newUrl.trim() || newEvents.size === 0) return
|
||||
const wh = createWebhook(newName, newUrl, [...newEvents])
|
||||
save([...webhooks, wh])
|
||||
setShowAdd(false); setNewName(''); setNewUrl(''); setNewEvents(new Set())
|
||||
}
|
||||
|
||||
function toggleEnabled(id: string) {
|
||||
save(webhooks.map(w => w.id === id ? { ...w, enabled: !w.enabled } : w))
|
||||
}
|
||||
|
||||
function deleteWebhook(id: string) { save(webhooks.filter(w => w.id !== id)) }
|
||||
|
||||
async function copySecret(secret: string) {
|
||||
await navigator.clipboard.writeText(secret)
|
||||
setCopied(secret); setTimeout(() => setCopied(null), 1500)
|
||||
}
|
||||
|
||||
const eventsFiltered = evFilter === 'all' ? ALL_EVENTS : ALL_EVENTS.filter(e => e.startsWith(evFilter + ':'))
|
||||
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto p-3 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs text-zinc-500">Outgoing HTTP POST webhooks fired on events.</p>
|
||||
<button onClick={() => setShowAdd(p => !p)}
|
||||
className="flex items-center gap-1 px-2 py-1 text-xs font-medium bg-indigo-600 hover:bg-indigo-700 text-white rounded-lg transition-colors">
|
||||
<Plus size={10} />Add
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showAdd && (
|
||||
<div className="bg-zinc-900 border border-zinc-700 rounded-xl p-3 space-y-3">
|
||||
<div>
|
||||
<label className="text-xs text-zinc-500 block mb-1">Name</label>
|
||||
<input value={newName} onChange={e => setNewName(e.target.value)} placeholder="My webhook"
|
||||
className="w-full text-xs font-mono px-2 py-1.5 bg-zinc-800 border border-zinc-700 rounded text-zinc-300 outline-none focus:border-indigo-500" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-zinc-500 block mb-1">URL</label>
|
||||
<input value={newUrl} onChange={e => setNewUrl(e.target.value)} placeholder="https://example.com/webhook"
|
||||
className="w-full text-xs font-mono px-2 py-1.5 bg-zinc-800 border border-zinc-700 rounded text-zinc-300 outline-none focus:border-indigo-500" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-zinc-500 block mb-1">Events</label>
|
||||
<div className="flex gap-1 mb-2 flex-wrap">
|
||||
{['all','cms','scorm','admin'].map(f => (
|
||||
<button key={f} onClick={() => setEvFilter(f)}
|
||||
className={`px-1.5 py-0.5 rounded text-[9px] uppercase tracking-wide transition-colors ${evFilter === f ? 'bg-zinc-600 text-white' : 'text-zinc-600 hover:text-zinc-400'}`}>{f}</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="max-h-32 overflow-y-auto space-y-0.5">
|
||||
{eventsFiltered.map(e => (
|
||||
<label key={e} className="flex items-center gap-2 px-1.5 py-0.5 rounded hover:bg-zinc-800 cursor-pointer">
|
||||
<input type="checkbox" checked={newEvents.has(e)}
|
||||
onChange={ev => { const n = new Set(newEvents); ev.target.checked ? n.add(e) : n.delete(e); setNewEvents(n) }}
|
||||
className="accent-indigo-500 w-3 h-3" />
|
||||
<span className="text-[9px] font-mono text-zinc-400">{e}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<button onClick={() => setNewEvents(new Set(ALL_EVENTS))}
|
||||
className="mt-1 text-[9px] text-indigo-400 hover:text-indigo-300 transition-colors">select all</button>
|
||||
</div>
|
||||
<div className="flex gap-2 justify-end">
|
||||
<button onClick={() => setShowAdd(false)} className="px-2 py-1 text-xs text-zinc-500 hover:text-zinc-300 border border-zinc-700 rounded-lg">Cancel</button>
|
||||
<button onClick={addWebhook} disabled={!newName.trim() || !newUrl.trim() || newEvents.size === 0}
|
||||
className="px-2 py-1 text-xs font-medium bg-indigo-600 hover:bg-indigo-700 disabled:opacity-40 text-white rounded-lg">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{webhooks.length === 0 && !showAdd && (
|
||||
<div className="flex flex-col items-center justify-center h-24 text-zinc-700">
|
||||
<WebhookIcon size={20} className="mb-2 opacity-40" />
|
||||
<p className="text-xs">No webhooks configured</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{webhooks.map(w => (
|
||||
<div key={w.id} className={`bg-zinc-900 border rounded-xl p-3 ${w.enabled ? 'border-zinc-700' : 'border-zinc-800 opacity-60'}`}>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-medium text-zinc-300">{w.name}</span>
|
||||
{w.lastStatus !== null && (
|
||||
<span className={`text-[9px] font-mono px-1 rounded ${w.lastStatus >= 200 && w.lastStatus < 300 ? 'bg-green-900/40 text-green-400' : 'bg-red-900/40 text-red-400'}`}>
|
||||
{w.lastStatus || 'ERR'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button onClick={() => toggleEnabled(w.id)} className="text-zinc-500 hover:text-zinc-300 transition-colors">
|
||||
{w.enabled ? <ToggleRight size={14} className="text-indigo-400" /> : <ToggleLeft size={14} />}
|
||||
</button>
|
||||
<button onClick={() => deleteWebhook(w.id)} className="text-zinc-600 hover:text-red-400 transition-colors"><Trash2 size={11} /></button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-[9px] font-mono text-zinc-500 truncate mb-1.5">{w.url}</p>
|
||||
<div className="flex items-center gap-1 mb-1.5">
|
||||
<span className="text-[9px] text-zinc-600 font-mono">secret:</span>
|
||||
<span className="text-[9px] font-mono text-zinc-600 flex-1 truncate">{w.secret.slice(0,12)}…</span>
|
||||
<button onClick={() => copySecret(w.secret)} className="text-zinc-600 hover:text-zinc-400 transition-colors">
|
||||
{copied === w.secret ? <Check size={10} className="text-green-400" /> : <Copy size={10} />}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{w.events.slice(0, 4).map(e => (
|
||||
<span key={e} className="text-[8px] font-mono bg-zinc-800 text-zinc-500 px-1 rounded">{e}</span>
|
||||
))}
|
||||
{w.events.length > 4 && <span className="text-[8px] text-zinc-600">+{w.events.length - 4} more</span>}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Main Panel ─────────────────────────────────────────────────
|
||||
type McpTab = 'events' | 'tools' | 'webhooks'
|
||||
|
||||
export function McpPanel({ onClose }: { onClose: () => void }) {
|
||||
const [tab, setTab] = useState<McpTab>('events')
|
||||
const [liveCount, setLiveCount] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
setLiveCount(EventBus.log.length)
|
||||
const unsub = EventBus.onLog(() => setLiveCount(c => c + 1))
|
||||
return unsub
|
||||
}, [])
|
||||
|
||||
const TABS: { id: McpTab; label: string; icon: React.ReactNode }[] = [
|
||||
{ id: 'events', label: 'Events', icon: <Zap size={11} /> },
|
||||
{ id: 'tools', label: 'Tools', icon: <Wrench size={11} /> },
|
||||
{ id: 'webhooks', label: 'Webhooks', icon: <WebhookIcon size={11} /> },
|
||||
]
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed top-0 right-0 bottom-0 z-50 flex flex-col shadow-2xl"
|
||||
style={{ width: 400, background: '#18181b', borderLeft: '1px solid rgba(255,255,255,0.08)' }}>
|
||||
{/* Header */}
|
||||
<div style={{ background: '#09090b', borderBottom: '1px solid rgba(255,255,255,0.06)' }}
|
||||
className="flex items-center justify-between px-4 py-3 flex-shrink-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-5 h-5 rounded bg-indigo-500 flex items-center justify-center">
|
||||
<Zap size={10} color="#fff" />
|
||||
</div>
|
||||
<span className="text-xs font-semibold text-zinc-100">MCP</span>
|
||||
<span className="text-[9px] text-zinc-600 font-mono">v1.0</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-green-400 animate-pulse" />
|
||||
<span className="text-xs text-zinc-500">{liveCount} fired</span>
|
||||
</div>
|
||||
<button onClick={onClose} className="text-zinc-600 hover:text-zinc-300 transition-colors"><X size={14} /></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex flex-shrink-0" style={{ borderBottom: '1px solid rgba(255,255,255,0.06)', background: '#09090b' }}>
|
||||
{TABS.map(t => (
|
||||
<button key={t.id} onClick={() => setTab(t.id)}
|
||||
className={`flex items-center gap-1.5 px-4 py-2.5 text-xs font-medium transition-colors flex-1 justify-center ${tab === t.id ? 'text-indigo-300 border-b border-indigo-500' : 'text-zinc-600 hover:text-zinc-400'}`}>
|
||||
{t.icon}{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-hidden flex flex-col">
|
||||
{tab === 'events' && <EventLogTab />}
|
||||
{tab === 'tools' && <ToolsTab />}
|
||||
{tab === 'webhooks' && <WebhooksTab />}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div style={{ borderTop: '1px solid rgba(255,255,255,0.06)', background: '#09090b' }}
|
||||
className="px-4 py-2 flex-shrink-0">
|
||||
<p className="text-[9px] text-zinc-700 font-mono">window.__CMS_MCP__ · {TOOLS.length} tools · DOM: cms:mcp:event</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Trigger button ─────────────────────────────────────────────
|
||||
export function McpTriggerButton({ onClick }: { onClick: () => void }) {
|
||||
const [pulse, setPulse] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const unsub = EventBus.onLog(() => { setPulse(true); setTimeout(() => setPulse(false), 600) })
|
||||
return unsub
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<button onClick={onClick}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium border rounded-md transition-all ${pulse ? 'border-indigo-400 text-indigo-500 bg-indigo-50' : 'border-zinc-200 text-zinc-500 hover:text-zinc-700 hover:border-zinc-300'}`}>
|
||||
<Zap size={12} className={pulse ? 'text-indigo-500' : ''} />MCP
|
||||
</button>
|
||||
)
|
||||
}
|
||||
178
Frontend/src/app/components/PageSettingsPanel.tsx
Normal file
178
Frontend/src/app/components/PageSettingsPanel.tsx
Normal file
@ -0,0 +1,178 @@
|
||||
import { useState } from 'react'
|
||||
import { Globe, Palette } from 'lucide-react'
|
||||
|
||||
import type { PageSEO, ThemeConfig } from '../types'
|
||||
import { ThemeBuilder } from './ThemeBuilder'
|
||||
|
||||
type PanelTab = 'theme' | 'seo'
|
||||
|
||||
function TextInput({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
type = 'text',
|
||||
testId,
|
||||
}: {
|
||||
label: string
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
placeholder?: string
|
||||
type?: 'text' | 'url'
|
||||
testId?: string
|
||||
}) {
|
||||
return (
|
||||
<label className="block space-y-1.5">
|
||||
<span className="text-xs font-medium text-zinc-600">{label}</span>
|
||||
<input
|
||||
data-testid={testId}
|
||||
type={type}
|
||||
value={value}
|
||||
onChange={event => onChange(event.target.value)}
|
||||
placeholder={placeholder}
|
||||
className="w-full rounded-md border border-zinc-200 bg-white px-2.5 py-1.5 text-xs outline-none transition-colors focus:border-indigo-400"
|
||||
/>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
function TextArea({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
testId,
|
||||
}: {
|
||||
label: string
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
placeholder?: string
|
||||
testId?: string
|
||||
}) {
|
||||
return (
|
||||
<label className="block space-y-1.5">
|
||||
<span className="text-xs font-medium text-zinc-600">{label}</span>
|
||||
<textarea
|
||||
data-testid={testId}
|
||||
value={value}
|
||||
onChange={event => onChange(event.target.value)}
|
||||
placeholder={placeholder}
|
||||
className="min-h-20 w-full resize-none rounded-md border border-zinc-200 bg-white px-2.5 py-1.5 text-xs outline-none transition-colors focus:border-indigo-400"
|
||||
/>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
function Toggle({
|
||||
label,
|
||||
checked,
|
||||
onChange,
|
||||
hint,
|
||||
testId,
|
||||
}: {
|
||||
label: string
|
||||
checked: boolean
|
||||
onChange: (checked: boolean) => void
|
||||
hint: string
|
||||
testId?: string
|
||||
}) {
|
||||
return (
|
||||
<label className="flex items-start justify-between gap-3 rounded-md border border-zinc-200 px-3 py-2.5">
|
||||
<span>
|
||||
<span className="block text-xs font-medium text-zinc-700">{label}</span>
|
||||
<span className="mt-0.5 block text-[11px] text-zinc-500">{hint}</span>
|
||||
</span>
|
||||
<input data-testid={testId} type="checkbox" checked={checked} onChange={event => onChange(event.target.checked)} className="mt-0.5 h-4 w-4 rounded border-zinc-300 text-indigo-600 focus:ring-indigo-500" />
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
export function PageSettingsPanel({
|
||||
pageName,
|
||||
slug,
|
||||
seo,
|
||||
theme,
|
||||
onSlugChange,
|
||||
onSEOChange,
|
||||
onThemeChange,
|
||||
}: {
|
||||
pageName: string
|
||||
slug: string
|
||||
seo: PageSEO
|
||||
theme: ThemeConfig
|
||||
onSlugChange: (slug: string) => void
|
||||
onSEOChange: (seo: PageSEO) => void
|
||||
onThemeChange: (theme: ThemeConfig) => void
|
||||
}) {
|
||||
const [tab, setTab] = useState<PanelTab>('theme')
|
||||
|
||||
const setSEO = (patch: Partial<PageSEO>) => onSEOChange({ ...seo, ...patch })
|
||||
const setOpenGraph = (patch: NonNullable<PageSEO['openGraph']>) => onSEOChange({ ...seo, openGraph: patch })
|
||||
const setRobots = (patch: NonNullable<PageSEO['robots']>) => onSEOChange({ ...seo, robots: patch })
|
||||
|
||||
return (
|
||||
<div data-testid="cms-page-settings-panel" className="flex h-full flex-col">
|
||||
<div className="border-b border-zinc-100 px-4 pt-4 pb-3">
|
||||
<h2 className="mb-0.5 text-xs font-semibold uppercase tracking-widest text-zinc-400">Page Settings</h2>
|
||||
<p className="truncate text-sm font-semibold text-zinc-800">{pageName}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-0.5 border-b border-zinc-100 px-3 pt-2">
|
||||
{[
|
||||
{ id: 'theme' as PanelTab, label: 'Theme', icon: Palette },
|
||||
{ id: 'seo' as PanelTab, label: 'SEO', icon: Globe },
|
||||
].map(({ id, label, icon: Icon }) => (
|
||||
<button
|
||||
data-testid={`cms-settings-tab-${id}`}
|
||||
key={id}
|
||||
onClick={() => setTab(id)}
|
||||
className={`-mb-px flex items-center gap-1.5 rounded-t-md border-b-2 px-3 py-1.5 text-xs font-medium transition-all ${
|
||||
tab === id ? 'border-indigo-500 bg-indigo-50/50 text-indigo-600' : 'border-transparent text-zinc-500 hover:text-zinc-700'
|
||||
}`}
|
||||
>
|
||||
<Icon size={11} />
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{tab === 'theme' ? (
|
||||
<ThemeBuilder theme={theme} onChange={onThemeChange} />
|
||||
) : (
|
||||
<div className="space-y-4 p-4">
|
||||
<div className="rounded-lg border border-indigo-100 bg-indigo-50 px-3 py-2 text-[11px] text-indigo-700">
|
||||
Leave the slug empty to let the backend generate it from the page name on save.
|
||||
</div>
|
||||
|
||||
<TextInput testId="cms-seo-slug" label="Slug" value={slug} onChange={onSlugChange} placeholder="landing-page" />
|
||||
<TextInput testId="cms-seo-title" label="SEO Title" value={seo.title ?? ''} onChange={value => setSEO({ title: value })} placeholder="Marketing landing page" />
|
||||
<TextArea testId="cms-seo-description" label="SEO Description" value={seo.description ?? ''} onChange={value => setSEO({ description: value })} placeholder="Short description for search and social cards" />
|
||||
<TextInput testId="cms-seo-canonical-url" label="Canonical URL" type="url" value={seo.canonicalUrl ?? ''} onChange={value => setSEO({ canonicalUrl: value })} placeholder="https://example.com/landing-page" />
|
||||
|
||||
<div className="border-t border-zinc-100 pt-4">
|
||||
<h3 className="mb-3 text-xs font-semibold uppercase tracking-widest text-zinc-500">Open Graph</h3>
|
||||
<div className="space-y-3">
|
||||
<TextInput testId="cms-seo-og-title" label="OG Title" value={seo.openGraph?.title ?? ''} onChange={value => setOpenGraph({ ...(seo.openGraph ?? {}), title: value })} placeholder="Social preview title" />
|
||||
<TextArea testId="cms-seo-og-description" label="OG Description" value={seo.openGraph?.description ?? ''} onChange={value => setOpenGraph({ ...(seo.openGraph ?? {}), description: value })} placeholder="Social preview description" />
|
||||
<TextInput testId="cms-seo-og-image" label="OG Image URL" type="url" value={seo.openGraph?.image ?? ''} onChange={value => setOpenGraph({ ...(seo.openGraph ?? {}), image: value })} placeholder="https://example.com/og-image.png" />
|
||||
<TextInput testId="cms-seo-og-url" label="OG URL" type="url" value={seo.openGraph?.url ?? ''} onChange={value => setOpenGraph({ ...(seo.openGraph ?? {}), url: value })} placeholder="https://example.com/landing-page" />
|
||||
<TextInput testId="cms-seo-og-type" label="OG Type" value={seo.openGraph?.type ?? ''} onChange={value => setOpenGraph({ ...(seo.openGraph ?? {}), type: value })} placeholder="website" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-zinc-100 pt-4">
|
||||
<h3 className="mb-3 text-xs font-semibold uppercase tracking-widest text-zinc-500">Robots</h3>
|
||||
<div className="space-y-2">
|
||||
<Toggle testId="cms-seo-robots-index" label="Index" checked={seo.robots?.index ?? true} onChange={value => setRobots({ ...(seo.robots ?? {}), index: value })} hint="Allow search engines to index this page." />
|
||||
<Toggle testId="cms-seo-robots-follow" label="Follow" checked={seo.robots?.follow ?? true} onChange={value => setRobots({ ...(seo.robots ?? {}), follow: value })} hint="Allow crawlers to follow links on this page." />
|
||||
<Toggle testId="cms-seo-robots-noarchive" label="No Archive" checked={seo.robots?.noarchive ?? false} onChange={value => setRobots({ ...(seo.robots ?? {}), noarchive: value })} hint="Prevent cached copies in search results." />
|
||||
<Toggle testId="cms-seo-robots-nosnippet" label="No Snippet" checked={seo.robots?.nosnippet ?? false} onChange={value => setRobots({ ...(seo.robots ?? {}), nosnippet: value })} hint="Prevent text snippets in search results." />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
190
Frontend/src/app/components/PagesModal.tsx
Normal file
190
Frontend/src/app/components/PagesModal.tsx
Normal file
@ -0,0 +1,190 @@
|
||||
import { useState } from 'react'
|
||||
import { X, Plus, Trash2, FileText, Clock, Layers, ChevronRight } from 'lucide-react'
|
||||
import type { Page } from '../App'
|
||||
|
||||
function formatDate(ts: number) {
|
||||
const d = new Date(ts)
|
||||
const now = Date.now()
|
||||
const diff = now - ts
|
||||
if (diff < 60_000) return 'Just now'
|
||||
if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`
|
||||
if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)}h ago`
|
||||
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: d.getFullYear() !== new Date().getFullYear() ? 'numeric' : undefined })
|
||||
}
|
||||
|
||||
function countBlocksDeep(blocks: Page['blocks']): number {
|
||||
let n = 0
|
||||
for (const b of blocks) {
|
||||
n++
|
||||
if (b.slots) for (const slot of b.slots) n += countBlocksDeep(slot)
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
export function PagesModal({
|
||||
pages,
|
||||
currentPageId,
|
||||
onLoad,
|
||||
onNew,
|
||||
onDelete,
|
||||
onClose,
|
||||
}: {
|
||||
pages: Page[]
|
||||
currentPageId: string | null
|
||||
onLoad: (page: Page) => void
|
||||
onNew: () => void
|
||||
onDelete: (id: string) => Promise<void>
|
||||
onClose: () => void
|
||||
}) {
|
||||
const [confirmDelete, setConfirmDelete] = useState<string | null>(null)
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="cms-pages-modal"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="cms-pages-title"
|
||||
style={{ position: 'fixed', inset: 0, zIndex: 60, display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'rgba(0,0,0,0.4)', backdropFilter: 'blur(4px)' }}
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
style={{ background: '#fff', borderRadius: '16px', width: '640px', maxWidth: '95vw', maxHeight: '80vh', display: 'flex', flexDirection: 'column', boxShadow: '0 24px 64px rgba(0,0,0,0.2)', overflow: 'hidden' }}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '1.25rem 1.5rem', borderBottom: '1px solid #f1f5f9' }}>
|
||||
<div>
|
||||
<h2 id="cms-pages-title" style={{ margin: 0, fontSize: '1rem', fontWeight: 700, color: '#0f172a' }}>Pages</h2>
|
||||
<p style={{ margin: '2px 0 0', fontSize: '0.75rem', color: '#94a3b8' }}>{pages.length} saved page{pages.length !== 1 ? 's' : ''}</p>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
|
||||
<button
|
||||
data-testid="cms-pages-create"
|
||||
onClick={onNew}
|
||||
style={{ display: 'flex', alignItems: 'center', gap: '0.375rem', padding: '0.4rem 0.875rem', background: '#6366f1', color: '#fff', border: 'none', borderRadius: '8px', cursor: 'pointer', fontSize: '0.8rem', fontWeight: 600 }}
|
||||
>
|
||||
<Plus size={13} />New page
|
||||
</button>
|
||||
<button
|
||||
onClick={onClose}
|
||||
aria-label="Close pages"
|
||||
style={{ width: 32, height: 32, display: 'flex', alignItems: 'center', justifyContent: 'center', border: 'none', background: '#f1f5f9', borderRadius: '8px', cursor: 'pointer', color: '#64748b' }}
|
||||
>
|
||||
<X size={15} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Page list */}
|
||||
<div style={{ flex: 1, overflowY: 'auto', padding: '0.75rem' }}>
|
||||
{pages.length === 0 ? (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', padding: '3rem 1rem', color: '#94a3b8' }}>
|
||||
<FileText size={36} style={{ opacity: 0.3, marginBottom: '0.75rem' }} />
|
||||
<p style={{ margin: 0, fontSize: '0.9rem', fontWeight: 500 }}>No saved pages yet</p>
|
||||
<p style={{ margin: '0.25rem 0 1rem', fontSize: '0.8rem' }}>Save your current canvas to get started</p>
|
||||
<button
|
||||
data-testid="cms-pages-create-first"
|
||||
onClick={onNew}
|
||||
style={{ display: 'flex', alignItems: 'center', gap: '0.375rem', padding: '0.5rem 1rem', background: '#6366f1', color: '#fff', border: 'none', borderRadius: '8px', cursor: 'pointer', fontSize: '0.8rem', fontWeight: 600 }}
|
||||
>
|
||||
<Plus size={13} />Create first page
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.375rem' }}>
|
||||
{pages.map(page => {
|
||||
const isCurrent = page.id === currentPageId
|
||||
const blockCount = countBlocksDeep(page.blocks)
|
||||
const isDeleting = confirmDelete === page.id
|
||||
return (
|
||||
<div
|
||||
data-testid={`cms-page-row-${page.id}`}
|
||||
key={page.id}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: '0.875rem',
|
||||
padding: '0.875rem 1rem', borderRadius: '10px',
|
||||
border: isCurrent ? '2px solid #6366f1' : '2px solid #f1f5f9',
|
||||
background: isCurrent ? '#fafafe' : '#fafafa',
|
||||
cursor: 'pointer', transition: 'all 0.12s',
|
||||
}}
|
||||
onClick={() => { if (!isDeleting) { onLoad(page); onClose() } }}
|
||||
>
|
||||
{/* Theme swatch */}
|
||||
<div style={{ flexShrink: 0, width: 44, height: 44, borderRadius: '8px', border: '1px solid #e2e8f0', overflow: 'hidden', display: 'grid', gridTemplateColumns: '1fr 1fr', gridTemplateRows: '1fr 1fr' }}>
|
||||
{[page.theme.background, page.theme.primary, page.theme.muted, page.theme.accent].map((c, i) => (
|
||||
<div key={i} style={{ background: /^#[0-9a-fA-F]{3,8}$/.test(c) ? c : '#e2e8f0' }} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||
<span style={{ fontSize: '0.875rem', fontWeight: 600, color: '#0f172a', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{page.name}
|
||||
</span>
|
||||
{isCurrent && (
|
||||
<span style={{ fontSize: '0.65rem', fontWeight: 700, background: '#6366f1', color: '#fff', padding: '1px 6px', borderRadius: '4px', letterSpacing: '0.04em', flexShrink: 0 }}>
|
||||
OPEN
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '0.75rem', marginTop: '0.2rem' }}>
|
||||
<span style={{ fontSize: '0.72rem', color: '#94a3b8', display: 'flex', alignItems: 'center', gap: '0.25rem' }}>
|
||||
<Layers size={10} />{blockCount} block{blockCount !== 1 ? 's' : ''}
|
||||
</span>
|
||||
<span style={{ fontSize: '0.72rem', color: '#94a3b8', display: 'flex', alignItems: 'center', gap: '0.25rem' }}>
|
||||
<Clock size={10} />Updated {formatDate(page.updatedAt)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div style={{ display: 'flex', gap: '0.375rem', flexShrink: 0 }} onClick={e => e.stopPropagation()}>
|
||||
{isDeleting ? (
|
||||
<>
|
||||
<span style={{ fontSize: '0.72rem', color: '#ef4444', fontWeight: 600, display: 'flex', alignItems: 'center' }}>Delete?</span>
|
||||
<button
|
||||
data-testid={`cms-page-delete-confirm-${page.id}`}
|
||||
onClick={() => { void onDelete(page.id).then(() => setConfirmDelete(null)).catch(() => undefined) }}
|
||||
style={{ padding: '0.25rem 0.625rem', background: '#ef4444', color: '#fff', border: 'none', borderRadius: '6px', cursor: 'pointer', fontSize: '0.72rem', fontWeight: 600 }}
|
||||
>
|
||||
Yes
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setConfirmDelete(null)}
|
||||
style={{ padding: '0.25rem 0.625rem', background: '#f1f5f9', color: '#64748b', border: 'none', borderRadius: '6px', cursor: 'pointer', fontSize: '0.72rem' }}
|
||||
>
|
||||
No
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
data-testid={`cms-page-delete-${page.id}`}
|
||||
aria-label={`Delete ${page.name}`}
|
||||
onClick={() => setConfirmDelete(page.id)}
|
||||
style={{ width: 28, height: 28, display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'transparent', border: '1px solid #e2e8f0', borderRadius: '6px', cursor: 'pointer', color: '#94a3b8' }}
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</button>
|
||||
<button
|
||||
data-testid={`cms-page-open-${page.id}`}
|
||||
aria-label={`Open ${page.name}`}
|
||||
onClick={() => { onLoad(page); onClose() }}
|
||||
style={{ width: 28, height: 28, display: 'flex', alignItems: 'center', justifyContent: 'center', background: '#f1f5f9', border: 'none', borderRadius: '6px', cursor: 'pointer', color: '#6366f1' }}
|
||||
>
|
||||
<ChevronRight size={14} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
195
Frontend/src/app/components/PreviewModal.tsx
Normal file
195
Frontend/src/app/components/PreviewModal.tsx
Normal file
@ -0,0 +1,195 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { X, Monitor, Tablet, Smartphone } from 'lucide-react'
|
||||
import type { ThemeConfig, CanvasBlock, CustomBlockDef, DataCacheEntry } from '../App'
|
||||
import { PageRenderer } from '../page-renderer'
|
||||
|
||||
const VIEWPORTS = [
|
||||
{ id: 'desktop', label: 'Desktop', icon: Monitor, width: '100%' },
|
||||
{ id: 'tablet', label: 'Tablet', icon: Tablet, width: '768px' },
|
||||
{ id: 'mobile', label: 'Mobile', icon: Smartphone, width: '390px' },
|
||||
] as const
|
||||
|
||||
type ViewportId = (typeof VIEWPORTS)[number]['id']
|
||||
|
||||
export function PreviewModal({
|
||||
blocks,
|
||||
customBlocks,
|
||||
theme,
|
||||
dataCache,
|
||||
onClose,
|
||||
}: {
|
||||
blocks: CanvasBlock[]
|
||||
customBlocks: CustomBlockDef[]
|
||||
theme: ThemeConfig
|
||||
dataCache: Record<string, DataCacheEntry>
|
||||
onClose: () => void
|
||||
}) {
|
||||
const [viewport, setViewport] = useState<ViewportId>('desktop')
|
||||
const dialogRef = useRef<HTMLDivElement>(null)
|
||||
const closeRef = useRef<HTMLButtonElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const previouslyFocused = document.activeElement instanceof HTMLElement ? document.activeElement : null
|
||||
closeRef.current?.focus()
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
onClose()
|
||||
return
|
||||
}
|
||||
if (e.key !== 'Tab' || !dialogRef.current) return
|
||||
const focusable = [...dialogRef.current.querySelectorAll<HTMLElement>('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])')]
|
||||
.filter(element => !element.hasAttribute('disabled'))
|
||||
if (focusable.length === 0) return
|
||||
const first = focusable[0]
|
||||
const last = focusable[focusable.length - 1]
|
||||
if (e.shiftKey && document.activeElement === first) {
|
||||
e.preventDefault()
|
||||
last.focus()
|
||||
} else if (!e.shiftKey && document.activeElement === last) {
|
||||
e.preventDefault()
|
||||
first.focus()
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', handler)
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handler)
|
||||
previouslyFocused?.focus()
|
||||
}
|
||||
}, [onClose])
|
||||
|
||||
const activeViewport = VIEWPORTS.find((v) => v.id === viewport)!
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={dialogRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="cms-preview-title"
|
||||
style={{
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
zIndex: 50,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
background: '#0f172a',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{/* Preview toolbar */}
|
||||
<div className="grid grid-cols-[minmax(0,1fr)_auto_auto] gap-2 px-2 sm:px-5" style={{
|
||||
alignItems: 'center',
|
||||
height: '48px',
|
||||
borderBottom: '1px solid rgba(255,255,255,0.08)',
|
||||
flexShrink: 0,
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||
<span id="cms-preview-title" style={{ fontSize: '0.8rem', fontWeight: 600, color: 'rgba(255,255,255,0.6)', letterSpacing: '0.05em', textTransform: 'uppercase' }}>
|
||||
Preview
|
||||
</span>
|
||||
<span className="hidden sm:inline-block" style={{ width: 1, height: 16, background: 'rgba(255,255,255,0.12)', margin: '0 0.25rem' }} />
|
||||
<span className="hidden sm:inline" style={{ fontSize: '0.75rem', color: 'rgba(255,255,255,0.35)' }}>{blocks.length} block{blocks.length !== 1 ? 's' : ''}</span>
|
||||
</div>
|
||||
|
||||
{/* Viewport switcher */}
|
||||
<div style={{ display: 'flex', gap: '2px', background: 'rgba(255,255,255,0.06)', borderRadius: '8px', padding: '3px' }}>
|
||||
{VIEWPORTS.map((v) => {
|
||||
const Icon = v.icon
|
||||
const active = viewport === v.id
|
||||
return (
|
||||
<button
|
||||
key={v.id}
|
||||
onClick={() => setViewport(v.id)}
|
||||
title={v.label}
|
||||
aria-label={`${v.label} preview`}
|
||||
aria-pressed={active}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.25rem',
|
||||
padding: '0.3rem 0.45rem',
|
||||
borderRadius: '6px',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
fontSize: '0.75rem',
|
||||
fontWeight: 500,
|
||||
transition: 'all 0.15s',
|
||||
background: active ? 'rgba(255,255,255,0.12)' : 'transparent',
|
||||
color: active ? '#fff' : 'rgba(255,255,255,0.4)',
|
||||
}}
|
||||
>
|
||||
<Icon size={13} />
|
||||
<span className="hidden sm:inline">{v.label}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<button
|
||||
ref={closeRef}
|
||||
onClick={onClose}
|
||||
aria-label="Close preview"
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.375rem',
|
||||
padding: '0.3rem 0.75rem',
|
||||
borderRadius: '6px',
|
||||
border: '1px solid rgba(255,255,255,0.12)',
|
||||
background: 'transparent',
|
||||
color: 'rgba(255,255,255,0.6)',
|
||||
cursor: 'pointer',
|
||||
fontSize: '0.75rem',
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
<X size={13} />
|
||||
<span className="hidden sm:inline">Close</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Preview area */}
|
||||
<div style={{
|
||||
flex: 1,
|
||||
overflow: 'auto',
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
padding: viewport === 'desktop' ? '0' : '2rem 1rem',
|
||||
background: viewport === 'desktop' ? 'transparent' : '#0f172a',
|
||||
}}>
|
||||
<div
|
||||
style={{
|
||||
width: activeViewport.width,
|
||||
maxWidth: '100%',
|
||||
background: theme.background,
|
||||
boxShadow: viewport !== 'desktop' ? '0 8px 48px rgba(0,0,0,0.5)' : 'none',
|
||||
borderRadius: viewport !== 'desktop' ? `${theme.radius * 2}rem` : 0,
|
||||
overflow: 'hidden',
|
||||
minHeight: '100%',
|
||||
transition: 'width 0.25s ease',
|
||||
}}
|
||||
>
|
||||
<PageRenderer blocks={blocks} customBlocks={customBlocks} theme={theme} dataCache={dataCache} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Viewport width badge */}
|
||||
{viewport !== 'desktop' && (
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
bottom: '1.25rem',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
background: 'rgba(255,255,255,0.08)',
|
||||
color: 'rgba(255,255,255,0.5)',
|
||||
fontSize: '0.7rem',
|
||||
padding: '4px 10px',
|
||||
borderRadius: '9999px',
|
||||
fontFamily: 'monospace',
|
||||
pointerEvents: 'none',
|
||||
}}>
|
||||
{activeViewport.width}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
272
Frontend/src/app/components/ThemeBuilder.tsx
Normal file
272
Frontend/src/app/components/ThemeBuilder.tsx
Normal file
@ -0,0 +1,272 @@
|
||||
import { useState } from 'react'
|
||||
import { Download, RotateCcw, ChevronDown, ChevronRight } from 'lucide-react'
|
||||
import type { ThemeConfig } from '../App'
|
||||
import { PRESET_THEMES } from '../App'
|
||||
|
||||
type ColorField = {
|
||||
key: keyof ThemeConfig
|
||||
label: string
|
||||
}
|
||||
|
||||
const COLOR_FIELDS: ColorField[] = [
|
||||
{ key: 'background', label: 'Background' },
|
||||
{ key: 'foreground', label: 'Foreground' },
|
||||
{ key: 'primary', label: 'Primary' },
|
||||
{ key: 'primaryForeground', label: 'Primary Foreground' },
|
||||
{ key: 'muted', label: 'Muted' },
|
||||
{ key: 'mutedForeground', label: 'Muted Foreground' },
|
||||
{ key: 'border', label: 'Border' },
|
||||
{ key: 'accent', label: 'Accent' },
|
||||
]
|
||||
|
||||
const FONTS = [
|
||||
{ label: 'Inter', value: 'Inter, ui-sans-serif, system-ui, sans-serif' },
|
||||
{ label: 'Geist', value: 'Geist, ui-sans-serif, system-ui, sans-serif' },
|
||||
{ label: 'Roboto', value: 'Roboto, ui-sans-serif, system-ui, sans-serif' },
|
||||
{ label: 'Plus Jakarta Sans', value: '"Plus Jakarta Sans", ui-sans-serif, system-ui, sans-serif' },
|
||||
{ label: 'Space Grotesk', value: '"Space Grotesk", ui-sans-serif, system-ui, sans-serif' },
|
||||
{ label: 'DM Sans', value: '"DM Sans", ui-sans-serif, system-ui, sans-serif' },
|
||||
{ label: 'Playfair Display', value: '"Playfair Display", ui-serif, Georgia, serif' },
|
||||
{ label: 'Lora', value: 'Lora, ui-serif, Georgia, serif' },
|
||||
{ label: 'JetBrains Mono', value: '"JetBrains Mono", ui-monospace, "Courier New", monospace' },
|
||||
]
|
||||
|
||||
function Section({ title, children, defaultOpen = true }: { title: string; children: React.ReactNode; defaultOpen?: boolean }) {
|
||||
const [open, setOpen] = useState(defaultOpen)
|
||||
return (
|
||||
<div className="border-b border-zinc-100">
|
||||
<button
|
||||
onClick={() => setOpen(!open)}
|
||||
className="w-full flex items-center justify-between px-4 py-3 hover:bg-zinc-50 transition-colors"
|
||||
>
|
||||
<span className="text-xs font-semibold text-zinc-500 uppercase tracking-widest">{title}</span>
|
||||
{open ? <ChevronDown size={13} className="text-zinc-400" /> : <ChevronRight size={13} className="text-zinc-400" />}
|
||||
</button>
|
||||
{open && <div className="px-4 pb-4">{children}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ColorRow({ label, value, onChange }: { label: string; value: string; onChange: (v: string) => void }) {
|
||||
const [hex, setHex] = useState(value)
|
||||
|
||||
const handleHexChange = (v: string) => {
|
||||
setHex(v)
|
||||
if (/^#[0-9a-fA-F]{6}$/.test(v)) onChange(v)
|
||||
}
|
||||
|
||||
const handlePickerChange = (v: string) => {
|
||||
setHex(v)
|
||||
onChange(v)
|
||||
}
|
||||
|
||||
// Sync when external value changes (preset applied)
|
||||
if (hex !== value && /^#[0-9a-fA-F]{6}$/.test(value)) {
|
||||
setHex(value)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between py-1.5 gap-2">
|
||||
<span className="text-xs text-zinc-600 flex-1 min-w-0 truncate">{label}</span>
|
||||
<div className="flex items-center gap-1.5 flex-shrink-0">
|
||||
<input
|
||||
type="color"
|
||||
aria-label={`${label} color picker`}
|
||||
value={/^#[0-9a-fA-F]{6}$/.test(value) ? value : '#888888'}
|
||||
onChange={(e) => handlePickerChange(e.target.value)}
|
||||
className="w-7 h-7 rounded cursor-pointer border border-zinc-200 p-0.5 bg-white"
|
||||
style={{ appearance: 'none' }}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
aria-label={`${label} color value`}
|
||||
value={hex}
|
||||
onChange={(e) => handleHexChange(e.target.value)}
|
||||
className="w-20 text-xs font-mono px-2 py-1 border border-zinc-200 rounded outline-none focus:border-indigo-400"
|
||||
placeholder="#000000"
|
||||
maxLength={7}
|
||||
spellCheck={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function exportCSS(theme: ThemeConfig): string {
|
||||
return `:root {
|
||||
--background: ${theme.background};
|
||||
--foreground: ${theme.foreground};
|
||||
--primary: ${theme.primary};
|
||||
--primary-foreground: ${theme.primaryForeground};
|
||||
--muted: ${theme.muted};
|
||||
--muted-foreground: ${theme.mutedForeground};
|
||||
--border: ${theme.border};
|
||||
--accent: ${theme.accent};
|
||||
--radius: ${theme.radius}rem;
|
||||
font-family: ${theme.fontFamily};
|
||||
}`
|
||||
}
|
||||
|
||||
export function ThemeBuilder({ theme, onChange }: { theme: ThemeConfig; onChange: (t: ThemeConfig) => void }) {
|
||||
const set = <K extends keyof ThemeConfig>(key: K, value: ThemeConfig[K]) => {
|
||||
onChange({ ...theme, [key]: value })
|
||||
}
|
||||
|
||||
const applyPreset = (presetKey: string) => {
|
||||
const preset = PRESET_THEMES[presetKey]
|
||||
if (!preset) return
|
||||
const { name: _name, ...rest } = preset
|
||||
onChange(rest)
|
||||
}
|
||||
|
||||
const handleExport = () => {
|
||||
const css = exportCSS(theme)
|
||||
const blob = new Blob([css], { type: 'text/css' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = 'theme.css'
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Header */}
|
||||
<div className="px-4 pt-4 pb-3 border-b border-zinc-100">
|
||||
<h2 className="text-xs font-semibold text-zinc-400 uppercase tracking-widest mb-3">Theme Builder</h2>
|
||||
|
||||
{/* Preset chips */}
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{Object.entries(PRESET_THEMES).map(([key, preset]) => (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => applyPreset(key)}
|
||||
title={preset.name}
|
||||
className="flex items-center gap-1.5 px-2 py-1 rounded-md border border-zinc-200 hover:border-zinc-400 text-xs text-zinc-600 transition-colors hover:bg-zinc-50"
|
||||
>
|
||||
<div
|
||||
className="w-3 h-3 rounded-full border border-zinc-300 flex-shrink-0"
|
||||
style={{ background: preset.primary }}
|
||||
/>
|
||||
{preset.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{/* Colors */}
|
||||
<Section title="Colors">
|
||||
<div className="space-y-0.5">
|
||||
{COLOR_FIELDS.map((field) => (
|
||||
<ColorRow
|
||||
key={field.key}
|
||||
label={field.label}
|
||||
value={theme[field.key] as string}
|
||||
onChange={(v) => set(field.key, v)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* Typography */}
|
||||
<Section title="Typography">
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="text-xs font-medium text-zinc-600 block mb-1.5">Font Family</label>
|
||||
<select
|
||||
aria-label="Font family"
|
||||
value={theme.fontFamily}
|
||||
onChange={(e) => set('fontFamily', e.target.value)}
|
||||
className="w-full text-xs border border-zinc-200 rounded-md px-2.5 py-1.5 outline-none focus:border-indigo-400 bg-white"
|
||||
>
|
||||
{FONTS.map((f) => (
|
||||
<option key={f.value} value={f.value}>{f.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* Shape */}
|
||||
<Section title="Shape">
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<label className="text-xs font-medium text-zinc-600">Border Radius</label>
|
||||
<span className="text-xs font-mono text-zinc-500">{theme.radius}rem</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
aria-label="Border radius"
|
||||
min={0}
|
||||
max={2}
|
||||
step={0.125}
|
||||
value={theme.radius}
|
||||
onChange={(e) => set('radius', parseFloat(e.target.value))}
|
||||
className="w-full accent-indigo-500"
|
||||
/>
|
||||
<div className="flex justify-between mt-1">
|
||||
<span className="text-xs text-zinc-400">Sharp</span>
|
||||
<span className="text-xs text-zinc-400">Rounded</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Preview swatch */}
|
||||
<div className="mt-3 flex gap-2">
|
||||
{[theme.primary, theme.muted, theme.accent].map((color, i) => (
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
width: 40,
|
||||
height: 32,
|
||||
background: /^#[0-9a-fA-F]{6}$/.test(color) ? color : '#888',
|
||||
borderRadius: `${theme.radius}rem`,
|
||||
border: '1px solid rgba(0,0,0,0.08)',
|
||||
transition: 'border-radius 0.2s, background 0.2s',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
<div style={{
|
||||
width: 40,
|
||||
height: 32,
|
||||
background: theme.background,
|
||||
borderRadius: `${theme.radius}rem`,
|
||||
border: `1px solid ${theme.border}`,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: '0.6rem', color: theme.foreground, fontWeight: 600,
|
||||
transition: 'all 0.2s',
|
||||
}}>
|
||||
Aa
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* CSS Preview */}
|
||||
<Section title="CSS Variables" defaultOpen={false}>
|
||||
<pre className="text-xs font-mono bg-zinc-900 text-zinc-100 rounded-md p-3 overflow-x-auto whitespace-pre-wrap leading-relaxed">
|
||||
{exportCSS(theme)}
|
||||
</pre>
|
||||
</Section>
|
||||
</div>
|
||||
|
||||
{/* Footer actions */}
|
||||
<div className="border-t border-zinc-100 px-4 py-3 flex gap-2">
|
||||
<button
|
||||
onClick={() => onChange(PRESET_THEMES.zinc)}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-xs border border-zinc-200 rounded-md hover:bg-zinc-50 text-zinc-600 transition-colors"
|
||||
>
|
||||
<RotateCcw size={12} />
|
||||
Reset
|
||||
</button>
|
||||
<button
|
||||
onClick={handleExport}
|
||||
className="flex-1 flex items-center justify-center gap-1.5 px-3 py-1.5 text-xs bg-indigo-600 text-white rounded-md hover:bg-indigo-700 transition-colors font-medium"
|
||||
>
|
||||
<Download size={12} />
|
||||
Export CSS
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
1095
Frontend/src/app/components/blocks.tsx
Normal file
1095
Frontend/src/app/components/blocks.tsx
Normal file
File diff suppressed because it is too large
Load Diff
27
Frontend/src/app/components/figma/ImageWithFallback.tsx
Normal file
27
Frontend/src/app/components/figma/ImageWithFallback.tsx
Normal file
@ -0,0 +1,27 @@
|
||||
import React, { useState } from 'react'
|
||||
|
||||
const ERROR_IMG_SRC =
|
||||
'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iODgiIGhlaWdodD0iODgiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgc3Ryb2tlPSIjMDAwIiBzdHJva2UtbGluZWpvaW49InJvdW5kIiBvcGFjaXR5PSIuMyIgZmlsbD0ibm9uZSIgc3Ryb2tlLXdpZHRoPSIzLjciPjxyZWN0IHg9IjE2IiB5PSIxNiIgd2lkdGg9IjU2IiBoZWlnaHQ9IjU2IiByeD0iNiIvPjxwYXRoIGQ9Im0xNiA1OCAxNi0xOCAzMiAzMiIvPjxjaXJjbGUgY3g9IjUzIiBjeT0iMzUiIHI9IjciLz48L3N2Zz4KCg=='
|
||||
|
||||
export function ImageWithFallback(props: React.ImgHTMLAttributes<HTMLImageElement>) {
|
||||
const [didError, setDidError] = useState(false)
|
||||
|
||||
const handleError = () => {
|
||||
setDidError(true)
|
||||
}
|
||||
|
||||
const { src, alt, style, className, ...rest } = props
|
||||
|
||||
return didError ? (
|
||||
<div
|
||||
className={`inline-block bg-gray-100 text-center align-middle ${className ?? ''}`}
|
||||
style={style}
|
||||
>
|
||||
<div className="flex items-center justify-center w-full h-full">
|
||||
<img src={ERROR_IMG_SRC} alt="Error loading image" {...rest} data-original-url={src} />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<img src={src} alt={alt} className={className} style={style} {...rest} onError={handleError} />
|
||||
)
|
||||
}
|
||||
66
Frontend/src/app/components/ui/accordion.tsx
Normal file
66
Frontend/src/app/components/ui/accordion.tsx
Normal file
@ -0,0 +1,66 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as AccordionPrimitive from "@radix-ui/react-accordion";
|
||||
import { ChevronDownIcon } from "lucide-react";
|
||||
|
||||
import { cn } from "./utils";
|
||||
|
||||
function Accordion({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AccordionPrimitive.Root>) {
|
||||
return <AccordionPrimitive.Root data-slot="accordion" {...props} />;
|
||||
}
|
||||
|
||||
function AccordionItem({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AccordionPrimitive.Item>) {
|
||||
return (
|
||||
<AccordionPrimitive.Item
|
||||
data-slot="accordion-item"
|
||||
className={cn("border-b last:border-b-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AccordionTrigger({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AccordionPrimitive.Trigger>) {
|
||||
return (
|
||||
<AccordionPrimitive.Header className="flex">
|
||||
<AccordionPrimitive.Trigger
|
||||
data-slot="accordion-trigger"
|
||||
className={cn(
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronDownIcon className="text-muted-foreground pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200" />
|
||||
</AccordionPrimitive.Trigger>
|
||||
</AccordionPrimitive.Header>
|
||||
);
|
||||
}
|
||||
|
||||
function AccordionContent({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AccordionPrimitive.Content>) {
|
||||
return (
|
||||
<AccordionPrimitive.Content
|
||||
data-slot="accordion-content"
|
||||
className="data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down overflow-hidden text-sm"
|
||||
{...props}
|
||||
>
|
||||
<div className={cn("pt-0 pb-4", className)}>{children}</div>
|
||||
</AccordionPrimitive.Content>
|
||||
);
|
||||
}
|
||||
|
||||
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };
|
||||
157
Frontend/src/app/components/ui/alert-dialog.tsx
Normal file
157
Frontend/src/app/components/ui/alert-dialog.tsx
Normal file
@ -0,0 +1,157 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog";
|
||||
|
||||
import { cn } from "./utils";
|
||||
import { buttonVariants } from "./button";
|
||||
|
||||
function AlertDialog({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
|
||||
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />;
|
||||
}
|
||||
|
||||
function AlertDialogTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDialogPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Overlay
|
||||
data-slot="alert-dialog-overlay"
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDialogContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Content>) {
|
||||
return (
|
||||
<AlertDialogPortal>
|
||||
<AlertDialogOverlay />
|
||||
<AlertDialogPrimitive.Content
|
||||
data-slot="alert-dialog-content"
|
||||
className={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</AlertDialogPortal>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDialogHeader({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-dialog-header"
|
||||
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDialogFooter({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-dialog-footer"
|
||||
className={cn(
|
||||
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDialogTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Title
|
||||
data-slot="alert-dialog-title"
|
||||
className={cn("text-lg font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Description
|
||||
data-slot="alert-dialog-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDialogAction({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Action>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Action
|
||||
className={cn(buttonVariants(), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDialogCancel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Cancel
|
||||
className={cn(buttonVariants({ variant: "outline" }), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
AlertDialog,
|
||||
AlertDialogPortal,
|
||||
AlertDialogOverlay,
|
||||
AlertDialogTrigger,
|
||||
AlertDialogContent,
|
||||
AlertDialogHeader,
|
||||
AlertDialogFooter,
|
||||
AlertDialogTitle,
|
||||
AlertDialogDescription,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
};
|
||||
66
Frontend/src/app/components/ui/alert.tsx
Normal file
66
Frontend/src/app/components/ui/alert.tsx
Normal file
@ -0,0 +1,66 @@
|
||||
import * as React from "react";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn } from "./utils";
|
||||
|
||||
const alertVariants = cva(
|
||||
"relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-card text-card-foreground",
|
||||
destructive:
|
||||
"text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
function Alert({
|
||||
className,
|
||||
variant,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert"
|
||||
role="alert"
|
||||
className={cn(alertVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-title"
|
||||
className={cn(
|
||||
"col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-description"
|
||||
className={cn(
|
||||
"text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Alert, AlertTitle, AlertDescription };
|
||||
11
Frontend/src/app/components/ui/aspect-ratio.tsx
Normal file
11
Frontend/src/app/components/ui/aspect-ratio.tsx
Normal file
@ -0,0 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio";
|
||||
|
||||
function AspectRatio({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AspectRatioPrimitive.Root>) {
|
||||
return <AspectRatioPrimitive.Root data-slot="aspect-ratio" {...props} />;
|
||||
}
|
||||
|
||||
export { AspectRatio };
|
||||
53
Frontend/src/app/components/ui/avatar.tsx
Normal file
53
Frontend/src/app/components/ui/avatar.tsx
Normal file
@ -0,0 +1,53 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as AvatarPrimitive from "@radix-ui/react-avatar";
|
||||
|
||||
import { cn } from "./utils";
|
||||
|
||||
function Avatar({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AvatarPrimitive.Root>) {
|
||||
return (
|
||||
<AvatarPrimitive.Root
|
||||
data-slot="avatar"
|
||||
className={cn(
|
||||
"relative flex size-10 shrink-0 overflow-hidden rounded-full",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AvatarImage({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
|
||||
return (
|
||||
<AvatarPrimitive.Image
|
||||
data-slot="avatar-image"
|
||||
className={cn("aspect-square size-full", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AvatarFallback({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
|
||||
return (
|
||||
<AvatarPrimitive.Fallback
|
||||
data-slot="avatar-fallback"
|
||||
className={cn(
|
||||
"bg-muted flex size-full items-center justify-center rounded-full",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Avatar, AvatarImage, AvatarFallback };
|
||||
46
Frontend/src/app/components/ui/badge.tsx
Normal file
46
Frontend/src/app/components/ui/badge.tsx
Normal file
@ -0,0 +1,46 @@
|
||||
import * as React from "react";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn } from "./utils";
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
|
||||
secondary:
|
||||
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
|
||||
destructive:
|
||||
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||
outline:
|
||||
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
function Badge({
|
||||
className,
|
||||
variant,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"span"> &
|
||||
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot : "span";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="badge"
|
||||
className={cn(badgeVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants };
|
||||
109
Frontend/src/app/components/ui/breadcrumb.tsx
Normal file
109
Frontend/src/app/components/ui/breadcrumb.tsx
Normal file
@ -0,0 +1,109 @@
|
||||
import * as React from "react";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { ChevronRight, MoreHorizontal } from "lucide-react";
|
||||
|
||||
import { cn } from "./utils";
|
||||
|
||||
function Breadcrumb({ ...props }: React.ComponentProps<"nav">) {
|
||||
return <nav aria-label="breadcrumb" data-slot="breadcrumb" {...props} />;
|
||||
}
|
||||
|
||||
function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
|
||||
return (
|
||||
<ol
|
||||
data-slot="breadcrumb-list"
|
||||
className={cn(
|
||||
"text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm break-words sm:gap-2.5",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
|
||||
return (
|
||||
<li
|
||||
data-slot="breadcrumb-item"
|
||||
className={cn("inline-flex items-center gap-1.5", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function BreadcrumbLink({
|
||||
asChild,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"a"> & {
|
||||
asChild?: boolean;
|
||||
}) {
|
||||
const Comp = asChild ? Slot : "a";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="breadcrumb-link"
|
||||
className={cn("hover:text-foreground transition-colors", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="breadcrumb-page"
|
||||
role="link"
|
||||
aria-disabled="true"
|
||||
aria-current="page"
|
||||
className={cn("text-foreground font-normal", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function BreadcrumbSeparator({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"li">) {
|
||||
return (
|
||||
<li
|
||||
data-slot="breadcrumb-separator"
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
className={cn("[&>svg]:size-3.5", className)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? <ChevronRight />}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
function BreadcrumbEllipsis({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="breadcrumb-ellipsis"
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
className={cn("flex size-9 items-center justify-center", className)}
|
||||
{...props}
|
||||
>
|
||||
<MoreHorizontal className="size-4" />
|
||||
<span className="sr-only">More</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Breadcrumb,
|
||||
BreadcrumbList,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
BreadcrumbEllipsis,
|
||||
};
|
||||
58
Frontend/src/app/components/ui/button.tsx
Normal file
58
Frontend/src/app/components/ui/button.tsx
Normal file
@ -0,0 +1,58 @@
|
||||
import * as React from "react";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn } from "./utils";
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||
outline:
|
||||
"border bg-background text-foreground hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
ghost:
|
||||
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
|
||||
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||
icon: "size-9 rounded-md",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
function Button({
|
||||
className,
|
||||
variant,
|
||||
size,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> &
|
||||
VariantProps<typeof buttonVariants> & {
|
||||
asChild?: boolean;
|
||||
}) {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="button"
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Button, buttonVariants };
|
||||
75
Frontend/src/app/components/ui/calendar.tsx
Normal file
75
Frontend/src/app/components/ui/calendar.tsx
Normal file
@ -0,0 +1,75 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { DayPicker } from "react-day-picker";
|
||||
|
||||
import { cn } from "./utils";
|
||||
import { buttonVariants } from "./button";
|
||||
|
||||
function Calendar({
|
||||
className,
|
||||
classNames,
|
||||
showOutsideDays = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DayPicker>) {
|
||||
return (
|
||||
<DayPicker
|
||||
showOutsideDays={showOutsideDays}
|
||||
className={cn("p-3", className)}
|
||||
classNames={{
|
||||
months: "flex flex-col sm:flex-row gap-2",
|
||||
month: "flex flex-col gap-4",
|
||||
caption: "flex justify-center pt-1 relative items-center w-full",
|
||||
caption_label: "text-sm font-medium",
|
||||
nav: "flex items-center gap-1",
|
||||
nav_button: cn(
|
||||
buttonVariants({ variant: "outline" }),
|
||||
"size-7 bg-transparent p-0 opacity-50 hover:opacity-100",
|
||||
),
|
||||
nav_button_previous: "absolute left-1",
|
||||
nav_button_next: "absolute right-1",
|
||||
table: "w-full border-collapse space-x-1",
|
||||
head_row: "flex",
|
||||
head_cell:
|
||||
"text-muted-foreground rounded-md w-8 font-normal text-[0.8rem]",
|
||||
row: "flex w-full mt-2",
|
||||
cell: cn(
|
||||
"relative p-0 text-center text-sm focus-within:relative focus-within:z-20 [&:has([aria-selected])]:bg-accent [&:has([aria-selected].day-range-end)]:rounded-r-md",
|
||||
props.mode === "range"
|
||||
? "[&:has(>.day-range-end)]:rounded-r-md [&:has(>.day-range-start)]:rounded-l-md first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md"
|
||||
: "[&:has([aria-selected])]:rounded-md",
|
||||
),
|
||||
day: cn(
|
||||
buttonVariants({ variant: "ghost" }),
|
||||
"size-8 p-0 font-normal aria-selected:opacity-100",
|
||||
),
|
||||
day_range_start:
|
||||
"day-range-start aria-selected:bg-primary aria-selected:text-primary-foreground",
|
||||
day_range_end:
|
||||
"day-range-end aria-selected:bg-primary aria-selected:text-primary-foreground",
|
||||
day_selected:
|
||||
"bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground",
|
||||
day_today: "bg-accent text-accent-foreground",
|
||||
day_outside:
|
||||
"day-outside text-muted-foreground aria-selected:text-muted-foreground",
|
||||
day_disabled: "text-muted-foreground opacity-50",
|
||||
day_range_middle:
|
||||
"aria-selected:bg-accent aria-selected:text-accent-foreground",
|
||||
day_hidden: "invisible",
|
||||
...classNames,
|
||||
}}
|
||||
components={{
|
||||
IconLeft: ({ className, ...props }) => (
|
||||
<ChevronLeft className={cn("size-4", className)} {...props} />
|
||||
),
|
||||
IconRight: ({ className, ...props }) => (
|
||||
<ChevronRight className={cn("size-4", className)} {...props} />
|
||||
),
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Calendar };
|
||||
Loading…
x
Reference in New Issue
Block a user