128 lines
7.1 KiB
Markdown
128 lines
7.1 KiB
Markdown
# 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`).
|