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 }