Add initial project files

This commit is contained in:
Igor Martyniuk
2026-09-01 13:03:41 +03:00
parent 22e111bb44
commit 74cb5b6176
1841 changed files with 242819 additions and 30 deletions
+127
View File
@@ -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`).