# Web Manager — Backend Backend of [Web Manager](./Readme.md). Source: `apps/backend/app/base_modules/WebManager/`. Lifecycle mechanics: [Page Lifecycle](./Readme.md#page-lifecycle). Cross-app flows: [Integration](./integration.md). ## Data Model ### `web_pages` ```txt id status -- draft | published | unpublished draft_of_id -- nullable FK to web_pages, ON DELETE CASCADE locale -- en, uk, de path -- '' for home, else 'about-us' or 'blog/post/about-me' (no leading slash) group_key -- links localized versions of the same page page_type -- landing | blog | blog-item title hero -- jsonb, nullable seo_title -- defaults to '' seo_description published_at -- nullable created_at / updated_at ``` Indexes: `(group_key, status)`, `(locale, path, status)`, a partial unique index on `(locale, path) WHERE status = 'published'`, and a partial unique index on `(draft_of_id) WHERE draft_of_id IS NOT NULL` — one draft clone per page, enforced by the database. Rules: - `path` is the full route after the locale, without leading or trailing slashes: `/en/blog/post/x` is `locale = en`, `path = blog/post/x`. The editor form shows it with a leading slash. - Public reads only ever match `status = published`. - A root row is `draft_of_id = null`; a draft clone points at its published original. - Route uniqueness is enforced twice: the partial index for live collisions, and `assertNewRouteAvailable` for root rows of any status, so an unpublished page keeps its address. ### `web_components` ```txt id page_id -- FK to web_pages, ON DELETE CASCADE type -- content | faq | media-content | articles | banner | media data -- jsonb, block-specific shape validated by validator/web_content_validator.ts sort_order -- position in the page, index of the submitted array created_at / updated_at ``` Indexed on `(page_id, sort_order)`. Saving a draft deletes and reinserts the whole set rather than diffing it. ### `web_settings` ```txt id locale -- unique header -- jsonb { menus: [{ title, items }], ctaItems } footer -- jsonb { copyright, menus } legal -- jsonb { privacyPath, termsPath } created_at / updated_at ``` One row per locale, upserted by locale. Shape lives in `WebStudio/config/siteSettings.ts`. ## HTTP API Admin routes are under `/web-manager` behind `middleware.auth()`; every action authorizes through `WebManagerPolicy`. | Method | Route | Permission | Purpose | | ------ | -------------------------------------- | ---------------------- | ------------------------------------------- | | GET | `/web-manager/pages` | `web_manager.view` | Paginated root rows with a `draft` relation | | GET | `/web-manager/pages/group-keys` | `web_manager.view` | Distinct group keys, alphabetized | | POST | `/web-manager/pages` | `web_manager.create` | Create a draft (multipart) | | GET | `/web-manager/pages/:id` | `web_manager.view` | Page + components | | PUT | `/web-manager/pages/:id/draft` | `web_manager.update` | Save a draft (multipart) | | POST | `/web-manager/pages/:id/publish` | `web_manager.publish` | Publish, returns `meta.revalidation` | | POST | `/web-manager/pages/:id/unpublish` | `web_manager.publish` | Take down, returns `meta.revalidation` | | POST | `/web-manager/pages/:id/preview-token` | `web_manager.view` | Signed preview token | | DELETE | `/web-manager/pages/:id` | `web_manager.delete` | Delete page or discard draft | | GET | `/web-manager/settings` | `web_manager.view` | Settings, all locales or one | | PUT | `/web-manager/settings` | `web_manager.settings` | Upsert settings for a locale | Public routes are under `/web-manager/public`, unauthenticated, and return published content only — except `preview`, which is gated by the signed token. | Route | Returns | | ------------------------------------ | ----------------------------------------------------------------------------------- | | `/pages/resolve?locale=&path=` | Page, components, settings, breadcrumbs, `languageAlternatives` | | `/pages/preview?token=` | The same shape for any status, for a valid token | | `/pages/sitemap?page=&perPage=` | `locale`, `path`, `groupKey`, `pageType`, `publishedAt`, `updatedAt` + `pagination` | | `/blog/pages?locale=&page=&perPage=` | Published `blog-item` pages, newest first, with hero media | | `/settings?locale=` | Header, footer, legal for one locale | The sitemap is the one public read proportional to the site, so it is paginated (`SITEMAP_PAGE_SIZE` 1000, capped at `SITEMAP_MAX_PAGE_SIZE` 5000) and the caller walks it; `groupKey` is included so hreflang alternates need no second lookup. The resolve payload carries `publishedAt` / `updatedAt`, which the web app emits as `datePublished` / `dateModified`. Permissions are seeded from `WebManager/config/permissions.ts`: `web_manager.view`, `.create`, `.update`, `.publish`, `.delete`, `.settings`. ## Public Resolve Query Budget | Query | Bounded by | | --------------------- | ----------------------------------------------------------- | | the page row | route lookup | | its components | blocks on the page | | site settings | one row per locale | | language alternatives | locales sharing the `group_key` | | breadcrumbs | path depth (~4–6 rows), plus one query per group-key jump | | internal page links | internal links in the page's own content, skipped when none | Two details keep that true. **Link map by id**: `collectInternalPageIds` walks hero, components and settings first, then `createPageLinkMap` fetches only those ids — unknown or unpublished ids are absent and render as an empty href. **Breadcrumbs by candidate**: the walk can only climb to a shorter prefix of the current path, the home page, or the group key in `BREADCRUMB_PAGE_TYPE_PARENTS`, so `buildBreadcrumbs` asks for precisely those rows; a second query happens only when a group-key jump lands on a branch whose ancestors were not already loaded (`ancestorsAlreadyLoaded`). Both are served by the existing indexes. ## Tuning Constants Backend tuning constants live in `WebManager/config/general.ts`: media upload path and 10 MB size limit, preview token TTLs, sitemap page sizes, and `BREADCRUMB_PAGE_TYPE_PARENTS` (which makes `blog` the breadcrumb parent of every `blog-item`).