7.1 KiB
Web Manager — Backend
Backend of Web Manager. Source: apps/backend/app/base_modules/WebManager/.
Lifecycle mechanics: Page Lifecycle. Cross-app flows: Integration.
Data Model
web_pages
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:
pathis the full route after the locale, without leading or trailing slashes:/en/blog/post/xislocale = 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
assertNewRouteAvailablefor root rows of any status, so an unpublished page keeps its address.
web_components
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
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).