Site pages
The clinic's public site: pages anyone may open, under the clinic's own address. Tables carry the site_ prefix (site_pages, site_redirects), as commerce tables carry commerce_. Staff and patients see "pagină publică" / "public page".
Status: layer A SHIPPED to production 2026-11-04 (000067). Owner decision: layered approach (option C). Public content pages came first. Layer B, the page builder for campaign landings, is next: design first. Its starting point is Layer B direction below and the checklist in features.md → F18.2.
What it is
A public page is the public, indexable face of one library item: a webinar first, then a program. The page is the library page without the sidebar, showing only what the clinic may say to a stranger, under a readable address:
https://{clinic-host}/webinar/miscarea-te-vindeca
https://{clinic-host}/program/genunchi-dupa-operatieThe type prefixes are webinar and program because both words are the same in Romanian and English, so the address reads correctly in either locale.
Staff turn a page on per item and choose its address name. A visitor who is not signed in reads the page and presses one button, which takes them through sign-in or sign-up to the item's own library page. Trial, enrollment and purchase happen there, exactly as today. A signed-in patient of the clinic sees the same page with "Deschide" in place of that button.
Two layers (owner, 2026-11-03)
| Layer | What | When |
|---|---|---|
| A. Content page | One page per item. It is the address search engines index, and it carries structured data (schema.org Event for webinar sessions, Course for programs). | This spec. |
| B. Campaign landing | Marketing copy per audience or channel, with UTMs and guest checkout. It reuses A's parts, and either stays out of search or names the content page as its main address. | Later, after its own planning. |
Owner decisions
| Decision | Answer |
|---|---|
| Which items | Webinars first, then programs. Sessions are not in the first version. |
| Opt-in | Off by default, turned on per item by staff. |
| Main address | The clinic's verified custom domain when it has one, otherwise {slug}.portal.restartix.pro. The page names that address as canonical, so search engines rank the clinic's own domain. |
| Changing the address name | The old name keeps working: it redirects (301) to the new one. |
| What leaves | A fixed projection of presentation columns, enforced by the classification registry (egress target site_page). Never a room link, a registrant count, trial or enrollment data, or anything about a person. |
| Signed-in visitor | Same page, with the button switching to "Deschide". |
| Buying from the page | Not in A. The button leads to the library page, which already sells. Guest checkout belongs to B. |
Data model
One new migration. Address names do not go on the content tables:
- a platform program has no clinic (
organization_idNULL), and each clinic that places it needs its own address name; - a webinar appears in the library whenever it is published, with or without a catalog placement.
A small table keyed by the clinic and the item covers all three content types.
site_pages
State, not an event log: one row per item a clinic has made public. Flat.
| Column | Notes |
|---|---|
id | UUID PK. |
organization_id | NOT NULL, FK organizations ON DELETE CASCADE. |
content_type | webinar | program (session is reserved and stays outside the CHECK until it is built). |
content_id | UUID. No FK, because it is typed-polymorphic like catalog_entries.content_id. A dangling page renders as 404. |
slug | The address name. ^[a-z0-9]+(-[a-z0-9]+)*$, 3 to 80 characters. Generated from the title with diacritics folded ("Mișcarea te vindecă" → miscarea-te-vindeca), then editable. |
published | BOOLEAN, default FALSE. published_at is set on the first publish. |
seo_title, seo_description | Optional overrides for search results and link previews. They default to the item's title and the first ~160 characters of its description. |
og_image_url | The link-preview image (og:image), on the public CDN; written only by the upload route. NULL = the item's cover. |
created_by_principal_id, created_at, updated_at | Standard. |
Constraints:
UNIQUE (organization_id, slug): one address namespace per clinic, across types, so/webinar/xand/program/xnever both exist.UNIQUE (organization_id, content_type, content_id): one page per item.
No deleted_at. Taking a page down sets published = false. The row holds no patient data, so staff may also delete it, and that delete is audited.
site_redirects
| Column | Notes |
|---|---|
organization_id, slug | PK. |
page_id | FK site_pages ON DELETE CASCADE. |
created_at |
A rename writes the old name here in the same transaction. Taking a name that sits in this table deletes the redirect row first, so the newest owner of a name wins. The service enforces that a name is never both live and a redirect, because one constraint cannot span the two tables.
RLS and permissions
There is no new permission family. A page is managed by whoever manages its item:
content_type | Read | Write |
|---|---|---|
webinar | webinars.view | webinars.manage |
program | catalog.read | catalog.manage |
The policies branch on content_type. Writes are audited (create, update, publish, unpublish, delete, rename).
Public reads go through the admin pool. They return published pages only, are rate-limited per IP, and project presentation columns only. This is the same path the campaign pages use (000038), so there is no public SELECT branch in RLS.
When a page renders
A published page renders only while its item would also show in the library. Otherwise the page answers 404 and is left out of the sitemap:
- webinar:
status = 'published', and its integration is still connected; - program: a published library template (never a patient's copy) that the clinic's library shows: a published placement on a published section, either the clinic's own placement (of its own or a platform program) or a platform-default placement of a platform program. The library's own rule (
programs_select_catalog, 000032). Without one, the visitor would sign up and find nothing.
The Clinic sheet says why a page is not live: the item is a draft, the webinar's provider connection is down, or the program is not in a published catalog section (item_not_in_catalog).
API
| Route | Auth | Returns |
|---|---|---|
GET /v1/public/pages/{slug} | Public. The org comes from X-Organization-ID, as for campaigns. | {data, redirect}: the page, or for an old name redirect: {content_type, slug}, so the Portal can answer 301. Unpublished and missing are both 404. |
GET /v1/public/pages?page&limit | Public | {content_type, slug, updated_at}, paginated, for the sitemap. |
GET/PUT/DELETE /v1/organizations/{id}/webinars/{webinarId}/site-page | webinars.view to read, webinars.manage to write | The settings: page (null while there is none), suggested_slug, live, not_live_reason, url. PUT creates or updates; an empty slug keeps the current name or takes the suggestion. |
POST/DELETE …/site-page/image (webinar) and /v1/site-pages/programs/{programId}/image | The item's manage permission | The link-preview image. Multipart, PNG/JPEG/WebP up to 2 MB, re-encoded to JPEG with the long edge at most 1200px on the public CDN (s3.SurfaceSitePageImages, platform/org-site-pages/{org}/{page}/). The page must exist. Audited. Answers the settings, which also carry item_cover_url, the preview's fallback. |
GET /v1/site-pages/redirects?q&type&page&limit | webinars.view or catalog.read; the kinds the caller may see | The clinic's old addresses, newest first: slug, the page it leads to (page_slug, item title, published), created_at, url and target_url. Search matches the old name, the current name or the title. The settings of one page carry its own as old_addresses (at most 50). |
DELETE /v1/site-pages/redirects/{slug} | webinars.manage or catalog.manage at the route; RLS decides by the page's type (403 site_redirect_not_managed) | Stops the old address answering and frees the name. Audited on the page. |
GET /v1/site-pages?q&type&status&sort&page&limit | webinars.view or catalog.read; the list holds the kinds the caller may see | The clinic's pages, newest change first: item title (null when the item is gone), slug, published, live, not_live_reason (also item_missing), url. Search is the title or the address name, word by word, diacritics folded; status is live or not_live. |
GET/PUT/DELETE /v1/site-pages/programs/{programId} | catalog.read to read, catalog.manage to write | The same, for a library program. The clinic comes from the request's organization context, as on /v1/catalog, and the program is the clinic's own template or a platform one. |
RequireURLOrgMatchesScope applies to the staff routes.
What the webinar content projection holds:
- title, description, cover;
- the access summary: free, paid, or included in plans, and whether a free trial is offered and for how long;
- the price, from the item's published placement product, as on the library page. It is left out when nothing sells the item;
- the next sessions (up to 10): start time, title, and whether one is live. They come from the 60-second schedule cache.
What the program content projection holds: what the library preview shows before enrolling: name, subtitle, description (sanitised HTML), their P21 translations, the program's cover (programs.cover_url, the same one the library page shows; a placement's cover override is a catalog-card setting and does not apply), the session count, the distinct exercises in first-use order (name, translations, thumbnail; at most 100), whether it is free (some placement the library shows asks for no plan), and the price the clinic's own placement sells it for. No exercise video plays on a public page, not even a free sample, and no plan requirement leaves.
Classification
New egress target site_page, enforced at build time like clinic_export: the projection is a list of columns in Go (sitepages.ProjectedColumns), and check-classification fails when one of them lacks a registry row allowing site_page. The new tables get their registry rows in the same PR.
Portal
Built for webinars (step 2) and programs (step 4).
- Route group
app/(public)/:webinar/[slug]andprogram/[slug], each loading throughsitePageFor, which also redirects (308) a name asked under the wrong type (/webinar/xwhen x is a program);- its own layout: the clinic logo or name, "Intrați în cont" (or "Contul meu" when signed in), a footer with the clinic's name, and no sidebar. The footer has no legal links yet: the Portal has no public legal pages to link to. It does name the medical device the page is part of (name, version, class, manufacturer, UDI-DI, a link to the IFU), from the one constant
packages/ui/src/lib/medical-device.ts, under every clinic's brand.
proxy.ts:/webinar/(.*)(with the slash, so the patient's own/webinars/…join routes stay behind sign-in),/robots.txtand/sitemap.xmlare public.- Shared parts. The page reuses the library page's parts unchanged:
StartCardwith its cover,OfferPanel(price, what taking part gives, the button) andWebinarClassesin itsscheduleOnlymode, fed by the public projection. - Metadata (
generateMetadata):- title
{title} · {clinic}and the description (the search override, or the start of the webinar's description cut at a word); alternates.canonical(the custom domain when there is one);- Open Graph and Twitter cards: the page's own image (1200 × 630, large card) when it has one, otherwise the item's square cover (small card), through
shareImage, plusog:site_name(the clinic) andog:locale(shareSite). These are what WhatsApp, Facebook and LinkedIn read when the address is shared; - a name that renders nothing is
noindex.
- title
- Structured data (JSON-LD):
- webinar: one
Eventper upcoming session, witheventAttendanceMode: OnlineEventAttendanceMode, aVirtualLocationpointing at the page,organizer(the clinic), andofferswhen a price exists (0 when free). Left out when no session is coming up; - program: a
Course, withprovider(the clinic) andoffers.
- webinar: one
- An old name answers with a permanent redirect (308) to the current one.
app/robots.ts(per host): allows/webinar/,/_next/(a crawler renders the page with its scripts and styles) and/favicon.ico, disallows everything else, and names the sitemap. Until it existed the Portal had norobots.txt, so sign-in and app routes were indexable.app/sitemap.ts(per host): every live page, each at its main address (urlfrom the API), whichever host serves the file.- The clinic on a first request. A crawler's request carries no cookie. The page sees the
org-idcookie the proxy sets on its way through; a route handler and the sitemap do not, so the public client resolves the clinic from the hostname when the cookie is missing, the way the proxy does (lib/site-pages.ts). - No shared cache. The API limits public reads per visitor IP, and a shared
unstable_cachewould make every page's reads come from the Portal's own address. Each request reads the API with the visitor's address forwarded, like the campaign pages. The API side is two indexed reads plus the schedule, which is cached there for a minute. - The button:
- visitor not signed in: goes through
/webinar/{slug}/start, which stores the library page in a one-hour cookie (pending-page) and sends the visitor to sign up withredirect_urlset to it. A new account joins the clinic first (/welcome), which then sends them to the library page and clears the cookie. Only /welcome reads it, so it can never pull a returning patient off the dashboard. The label follows the item: "Înscrieți-vă gratuit" when free, "Încercați gratuit" when a trial is offered, "Abonați-vă" or "Cumpărați" when priced, "Vreau să particip" otherwise. Under it, "Aveți deja cont? Intrați în cont" signs in straight back to the library page; - signed-in visitor: "Deschideți webinarul", a link to the library page.
- visitor not signed in: goes through
Clinic
Pagini publice (/site-pages, Content group of the menu, shown with webinars.view or catalog.read) has two tabs in the Sales tab layout (SectionLayout section="sitePages"): Pagini and Redirecționări (/site-pages/redirects, every old address with the page it leads to and since when, Remove behind a confirmation for the kinds of page the caller manages). It is the ONLY place pages are managed (owner, 2026-11-04): the item menus on Webinars, Catalog and Programs no longer carry one. It lists every page of the clinic in one table: the item and the page's address, the type, where it stands (a green pill when public, grey when switched off, amber when its item holds it back: draft, not in the catalog, connection down, deleted), when it last changed, and a link out while it is live. Search, type and status are server-side and in the URL.
One sheet for both types, SitePageSheet (components/site-pages/), on the right like the prescription sheet. It reads its settings through /api/site-pages/{type}/{id} and saves with putSitePageAction (lib/actions/site-pages.ts). It opens:
- from a row's title, on that page;
- from "Pagină nouă" (shown for the kinds the caller manages:
webinars.manage,catalog.manage), on a webinar or program chosen first by a server-side search (/api/webinars,/api/programs/pickable: published library programs, the clinic's and platform ones). An item that already has a page opens that page.
It shows:
- where the page stands, and when it is not up, why: the item is a draft, the webinar's provider connection is down, the program is not in a published catalog section, or the page is switched off;
- the "Publicată" switch, on by default for a new page (opening the sheet is asking for one; nothing is saved until Save);
- the address name after its host prefix (
https://{host}/webinar/), folded and hyphenated as it is typed, refused on save when the API would refuse it, with a note that a rename keeps the old address working; - the address with a copy button inside the field, once the page is saved, and under it the page's old addresses, each removable (hidden while there are none);
- optional title and description for search and shares, with counters, falling back to the item's own;
- the link preview as WhatsApp or Facebook draw it (image, host, title, description), following what is typed, with the image picker under it: a 1.91:1 crop, uploaded at once; "Elimină imaginea" brings the cover back. A new page is saved first, since the image belongs to the page;
- "Deschide pagina" in the footer while live.
Removing a page outright exists in the API but not in the sheet: switching it off is enough, and keeps its name reserved.
Medical device
A public page describing a program advertises a CE-marked device. Under MDR Art. 7 its text may not claim more than the declared intended purpose. The text is the clinic's own (title, description, search text), so this is a copy-review responsibility rather than a code one. Flag for the owner: whether a public page needs a disclaimer line or a review step before a clinic publishes it.
Data protection
The page carries no personal data. A visitor gets only the cookies the public Portal routes already set (the org cookies, and Clerk on sign-in). Campaign pages already have that same posture. A trainer's name appears only if the clinic writes it into the description.
Out of scope for A
- Campaign landings, UTM capture through sign-up, and guest checkout from the page (layer B).
- Session pages, alternate-language versions of a page (
hreflang), and per-page analytics.
Layer B direction: a page builder (not designed)
The hand-off for whoever starts layer B: this section, the "Addresses" paragraph at its end, and the checklist in features.md → F18.2. The first deliverable is a design document for the owner, not code.
Owner, 2026-11-04: campaign landings will come from a page builder at least as capable as the one in restartix-webinars, where staff write pages with AI. It is one model, not a second one beside A:
- One table. A builder page is a
site_pagesrow. An item page is a site page bound to an item, and a free-form landing is one with no item, socontent_type/content_idbecome nullable then, not now. - Commerce stays commerce. What a landing sells, and how (
commerce_campaignsand its options, guest checkout, UTMs), stays in commerce. A landing page points at a campaign. The campaign's presentation columns (headline,blurb,image_url) move to the page once the builder exists. - Content in versions. Page content gets its own versioned rows (
site_page_versionsor blocks), with the published version recorded in the database.
Lessons from the restartix-webinars builder (surveyed 2026-11-04). Each is a requirement here, not a nicety:
- AI-written HTML is untrusted. There, raw HTML with scripts is served unsanitized with no CSP, template values are inserted unescaped, and the editor preview runs the page on the dashboard's own origin. Here a builder page shares the Portal's origin with patient sign-in. It therefore needs a sandboxed preview on a separate origin, a strict CSP on the public page, no page-authored scripts, and template values escaped for where they land.
- Live parts are ours. The form, the buy button, the countdown and the tracking are platform components placed into the page. They are not
{{…}}string expansion or model-written inline scripts. - Structure over one opaque document. Sections or blocks, possibly with AI-written markup inside each, instead of one HTML file patched by line numbers and spliced at
</body>. - CSS built deterministically. No Tailwind CDN at runtime, and no per-publish CLI that silently falls back to the CDN.
- Generation jobs survive a deploy. A persisted job queue, not a goroutine with a timeout.
- Reference fetching is SSRF-safe, and stored prompts follow retention and erasure (they can carry testimonials, which are personal data).
- Nothing third-party before consent. No Google Fonts or CDN loads before the cookie choice.
- The page type is explicit. Generation guidance follows the page's type field, not keywords in the request.
Addresses (owner, 2026-11-04). Layer A ships with the fixed prefixes /webinar/{slug} and /program/{slug}; the builder decides the final scheme, once, for item pages and free-form landings alike. The candidates were: other fixed words for every clinic, a word each clinic picks, or no prefix at all (/{slug}). The data already allows the last one, since a name is unique per clinic across types. Its cost is in the Portal: every top-level app route becomes a reserved word, and the proxy must tell a public page from a private one by name. Whatever is chosen, the current prefixes stay as permanent pattern redirects (/program/{slug} to the new address). That is one rule per prefix, not a row per page, so shared links, QR codes and search ranking carry over. Redirect rows convert mechanically to full paths.
Manual redirects (an extra address a clinic adds for a page, such as a short name for print or a campaign) are deferred to the same point. Within the current prefixes they would be site_redirects rows with a source marker. Free-form paths and external targets are not offered: an external target would make the clinic's domain an open redirect.
Build order
- Database: the migration, the domain
sitepages, the staff and public routes, thesite_pageegress target, and the OpenAPI entries. - Portal: the
(public)layout, the webinar page,robots.tsandsitemap.ts. - Clinic: the dialog on the webinar.
- Programs: the program projection and page, and the dialog on the catalog placement.
- Pagini publice (2026-11-04): the one place pages are managed, a sheet instead of the dialog, the link-preview image, and the Redirecționări tab.