TASKS-1823 WebManager module
This commit is contained in:
@@ -0,0 +1,127 @@
|
||||
# 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`).
|
||||
Reference in New Issue
Block a user