Skip to content

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-operatie

The 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) ​

LayerWhatWhen
A. Content pageOne 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 landingMarketing 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 ​

DecisionAnswer
Which itemsWebinars first, then programs. Sessions are not in the first version.
Opt-inOff by default, turned on per item by staff.
Main addressThe 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 nameThe old name keeps working: it redirects (301) to the new one.
What leavesA 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 visitorSame page, with the button switching to "Deschide".
Buying from the pageNot 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_id NULL), 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.

ColumnNotes
idUUID PK.
organization_idNOT NULL, FK organizations ON DELETE CASCADE.
content_typewebinar | program (session is reserved and stays outside the CHECK until it is built).
content_idUUID. No FK, because it is typed-polymorphic like catalog_entries.content_id. A dangling page renders as 404.
slugThe 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.
publishedBOOLEAN, default FALSE. published_at is set on the first publish.
seo_title, seo_descriptionOptional overrides for search results and link previews. They default to the item's title and the first ~160 characters of its description.
og_image_urlThe 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_atStandard.

Constraints:

  • UNIQUE (organization_id, slug): one address namespace per clinic, across types, so /webinar/x and /program/x never 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 ​

ColumnNotes
organization_id, slugPK.
page_idFK 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_typeReadWrite
webinarwebinars.viewwebinars.manage
programcatalog.readcatalog.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 ​

RouteAuthReturns
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&limitPublic{content_type, slug, updated_at}, paginated, for the sitemap.
GET/PUT/DELETE /v1/organizations/{id}/webinars/{webinarId}/site-pagewebinars.view to read, webinars.manage to writeThe 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}/imageThe item's manage permissionThe 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&limitwebinars.view or catalog.read; the kinds the caller may seeThe 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&limitwebinars.view or catalog.read; the list holds the kinds the caller may seeThe 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 writeThe 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] and program/[slug], each loading through sitePageFor, which also redirects (308) a name asked under the wrong type (/webinar/x when 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.txt and /sitemap.xml are public.
  • Shared parts. The page reuses the library page's parts unchanged: StartCard with its cover, OfferPanel (price, what taking part gives, the button) and WebinarClasses in its scheduleOnly mode, 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, plus og:site_name (the clinic) and og:locale (shareSite). These are what WhatsApp, Facebook and LinkedIn read when the address is shared;
    • a name that renders nothing is noindex.
  • Structured data (JSON-LD):
    • webinar: one Event per upcoming session, with eventAttendanceMode: OnlineEventAttendanceMode, a VirtualLocation pointing at the page, organizer (the clinic), and offers when a price exists (0 when free). Left out when no session is coming up;
    • program: a Course, with provider (the clinic) and offers.
  • 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 no robots.txt, so sign-in and app routes were indexable.
  • app/sitemap.ts (per host): every live page, each at its main address (url from 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-id cookie 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_cache would 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 with redirect_url set 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.

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_pages row. An item page is a site page bound to an item, and a free-form landing is one with no item, so content_type / content_id become nullable then, not now.
  • Commerce stays commerce. What a landing sells, and how (commerce_campaigns and 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_versions or 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 ​

  1. Database: the migration, the domain sitepages, the staff and public routes, the site_page egress target, and the OpenAPI entries.
  2. Portal: the (public) layout, the webinar page, robots.ts and sitemap.ts.
  3. Clinic: the dialog on the webinar.
  4. Programs: the program projection and page, and the dialog on the catalog placement.
  5. 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.