API Contract — Aletheia ↔ Next.js¶
Date: 2026-03-27 Status: Draft (finalize during A2) Depends on: visual_design.md, content_editing.md, data_audit_gap_analysis.md
Context¶
Helios is a multi-tenant website platform for 9 dental practices. Aletheia (Django 5.2, ~/coding/aletheia/aletheia_v2/) is the existing practice management backend — a new apps/websites/ module serves as the CMS. Next.js 16 (this repo's future frontend) consumes Aletheia's REST API over a private network (OVH vRack) to render practice websites.
This document defines the API boundary between the two codebases and the process for keeping them in sync.
Codebase References¶
| Aletheia (backend) | Helios (frontend) | |
|---|---|---|
| GitHub | baudry-suffren/aletheia_v2 | TBD (create during B1) |
| Local path | ~/coding/aletheia/aletheia_v2/ |
~/coding/helios/helios_test2/ |
| Language | Python 3.13, Django 5.2, DRF | TypeScript, Next.js 16, Tailwind |
| Deploy | OVH VPS #1 (54.36.99.184) |
Same VPS initially, optional VPS #2 later (see infra_contract.md) |
| Live URL | aletheia.groupe-suffren.com |
Per-practice domains (e.g., cabinet-dentaire-aubagne.fr) |
Key Files — Aletheia (what Helios depends on)¶
~/coding/aletheia/aletheia_v2/
├── apps/
│ ├── practices/models.py ← Practice, PracticeBusinessHour, PracticeEquipment, PracticeRoom
│ ├── dentists/models.py ← Dentist, DentistContract, DentistSkill, DentistTraining, DentistWorkSchedule
│ └── websites/ ← NEW (A1) — all website-specific models
│ ├── models.py ← SiteConfig, Page, ContentBlock, CaseStudy, Testimonial, MediaFile, ContactSubmission
│ ├── serializers.py ← DRF serializers (A2) — defines the JSON shapes in §3
│ ├── views.py ← DRF viewsets (A2) + /web/ editing views (A3)
│ ├── urls.py ← API routes (/api/v1/websites/...) + editing routes (/web/...)
│ ├── schemas/ ← JSON schemas for ContentBlock validation (per block_type)
│ └── signals.py ← Post-save signals → ISR revalidation webhook (A5)
├── config/
│ ├── settings/ ← Django settings (DB, Redis, Celery, Brevo)
│ └── urls.py ← Root URL config (includes /api/, /web/, /group/)
Key Files — Helios (what Aletheia feeds into)¶
~/coding/helios/helios_test2/ ← Currently: specs only. Next.js code starts at B1.
├── spec_helios.md ← Full functional spec
├── decisions/
│ ├── visual_design.md ← Design system, tokens, components, templates
│ ├── api_contract.md ← THIS FILE — API boundary
│ ├── infra_contract.md ← Server architecture, local dev, deploy, monitoring
│ ├── content_editing.md ← CMS architecture decisions
│ └── redirect_engine.md
├── data_audit_gap_analysis.md ← What Aletheia has vs what the website needs
└── next_steps.md ← Roadmap + implementation workstreams
After B1 scaffold, the Next.js code will live in this repo (or a new one — TBD).
1. Process — How Spec Changes Flow¶
The Problem¶
Aletheia (Django) and Helios (Next.js) are developed in two separate codebases. Design decisions made in the Helios spec may hit Aletheia constraints during implementation. Without a sync mechanism, the specs drift silently.
The Rule¶
This document is the shared boundary. Both codebases reference it.
- Next.js cares about: endpoint URLs, JSON payload shapes, query parameters, media URL patterns.
- Aletheia is free to implement however it wants (model design, admin views, permissions) as long as the API contract holds.
- When Aletheia can't deliver what's specced: update this document first (with a dated note in §7 Changelog), then adapt the frontend.
Change Process¶
- Developer hits a constraint in Aletheia that affects what the API returns.
- Add a dated entry to §7 Changelog explaining: what changed, why, and what frontend impact.
- If the change affects visual_design.md or spec_helios.md, update those too with a cross-reference.
- Next time you context-switch to Next.js work, read the Changelog first.
Source of Truth¶
| Concern | Source of truth |
|---|---|
| What data exists and how it's stored | Aletheia codebase (apps/) |
| What the API returns | This document |
| What the frontend needs to display | visual_design.md + spec_helios.md |
| ContentBlock JSON schemas | Aletheia codebase (validated there), documented here |
2. API Overview¶
Base URL: Configured via ALETHEIA_API_URL environment variable.
- Local dev: http://localhost:8000/api/v1/websites/
- Server (Option A): http://aletheia-{env}-web:8000/api/v1/websites/ (Docker network)
- Server (Options B/C): http://10.0.0.1/api/v1/websites/ (vRack private network)
- See infra_contract.md §2-3 for full setup details.
Auth: None needed (private network, read-only for Next.js). Write endpoints (contact form) use CSRF or API key.
Format: JSON. All responses use consistent envelope:
Shared base + per-practice override (page resolution)¶
The website content model is a two-layer stack: a shared base authored
by the central team (rows with practice IS NULL in Page / ContentBlock)
plus optional per-practice overrides (rows with practice = <id> at the
same slug). The override mechanism propagates new shared content
automatically — practices receive every shared-base update for free unless
they have opted to override a specific page.
Resolution rules (enforced server-side; Helios does not reimplement):
GET /sites/{domain}/pages/{slug}/looks up the per-practice row first, then falls back to the shared row when the practice has no override at that slug. Same slug → practice wins.GET /sites/{domain}/pages/unions the practice's published pages with the shared library, deduplicates byslugwith practice rows ranked first, and returns the merged set.- Custom pages (template
custom, no shared counterpart) are returned only for the owning practice.
Propagation consequences:
- Adding a new page to the shared base appears on every practice site at the next request — no per-practice work required.
- Editing an existing shared page propagates the change to all practices that have not created an override at that slug. Practices that have overridden the page keep their version; the shared update does not merge into their copy (no field-level sync).
- Removing a shared page hides it from every practice that did not override it. Per-practice overrides survive shared deletion.
- Renaming a shared page would orphan all per-practice overrides at the
old slug. Slugs of canonical shared pages are therefore reserved and
cannot be renamed once seeded — enforced by
Page.clean()againstapps.websites.models.RESERVED_PAGE_SLUGS(the 9 Mandatory + 3 Optional protected slugs from the shared-base buildout decision matrix).
Per-practice page visibility (opt-out at the page level for Optional
shared pages, without forking the content) is a separate mechanism
landing later in the buildout — see
roadmap/done/websites-shared-base-buildout.md item N8.
Endpoints¶
| Method | Endpoint | Purpose | Consumer |
|---|---|---|---|
GET |
/sites/{domain}/config/ |
Practice theme, branding, enabled features | Next.js proxy (Host resolution) |
GET |
/sites/{domain}/pages/ |
Page list (slug, title, template, status) | Next.js sitemap, nav generation |
GET |
/sites/{domain}/pages/{slug}/ |
Full page with ordered ContentBlocks | Next.js page rendering |
GET |
/sites/{domain}/practice/ |
Practice structured data (address, hours, team, contact) | Next.js layout, footer, Schema.org |
GET |
/sites/{domain}/team/ |
Practitioner list with skills, training, schedules | Next.js team page, homepage preview |
GET |
/sites/{domain}/team/{slug}/ |
Single practitioner detail | Next.js practitioner page |
GET |
/sites/{domain}/case-studies/ |
Before/after cases (filterable by treatment) | Next.js results page, service cross-links |
GET |
/sites/{domain}/testimonials/ |
Patient testimonials (filterable by treatment) | Next.js testimonial sections |
GET |
/sites/{domain}/nav/ |
Navigation tree (service categories, patient needs, static pages) | Next.js nav component |
POST |
/sites/{domain}/contact/ |
Contact form submission | Next.js contact form |
Direction note. Every row above is an Aletheia endpoint that Helios consumes. The ISR revalidation webhook is the lone exception and runs the other way:
POST /webhooks/revalidate/is a Helios-hosted route (Next.js route handler) that Aletheia calls on a content change. It is documented in §3.6 and is not part of Aletheia's/api/v1/websites/surface.
3. Payload Shapes¶
3.1 SiteConfig — GET /sites/{domain}/config/¶
{
"data": {
"domain": "cabinet-dentaire-aubagne.fr",
"practice_name": "Cabinet Dentaire d'Aubagne",
"practice_short_name": "Cabinet d'Aubagne",
"umami_website_id": "2c6ff41e-2b5d-4163-939e-46523c23b066",
"logo_url": "/media/practices/aubagne/logo.svg",
"og_image_url": "/media/sites/og/CDA.png",
"theme": {
"primary_hue": 195,
"primary_chroma": 0.12,
"accent_hue": 70,
"accent_chroma": 0.14
},
"seo": {
"city_name": "Aubagne",
"meta_description_default": "..."
}
}
}
Key design decisions:
- The theme sends hue + chroma values. Next.js generates the full OKLCH palette client-side using the formulas in visual_design.md §3. This keeps SiteConfig simple (2 numbers per color) while the full token set is derived in CSS.
- Fonts are not sent — Helios hardcodes --font-dm-sans / --font-dm-serif. The former theme.heading_font / theme.body_font were dropped 2026-06-01 (see §7).
- practice_id / practice_code, favicon_url, enabled_locales, enabled_services, and seo.meta_title_template were dropped 2026-06-01 — none were read by Helios (it keys off domain, hardcodes favicon/i18n, derives services from the nav, and the server already applies the title template in the page seo).
- logo_url is unchanged on the wire (absolute URL string or null) but now resolves from a MediaFile library reference (SiteConfig.logo_media) rather than a per-config upload — image-picker convergence, internal-only (see §7, 2026-06-01).
3.2 Page with ContentBlocks — GET /sites/{domain}/pages/{slug}/¶
{
"data": {
"id": 42,
"slug": "implant-dentaire-aubagne",
"title": "Implantologie à Aubagne",
"template": "service_hub",
"status": "published",
"published_at": "2026-03-27T10:00:00Z",
"url": "/implant-dentaire-aubagne/",
"seo": {
"meta_title": "Implant dentaire Aubagne | Cabinet Dentaire",
"meta_description": "...",
"canonical_url": "/implant-dentaire-aubagne/"
},
"breadcrumbs": [
{ "label": "Accueil", "url": "/" },
{ "label": "Implantologie Aubagne", "url": "/implant-dentaire-aubagne/" }
],
"blocks": [
{
"id": 101,
"block_type": "hero",
"position": 0,
"is_visible": true,
"content": {
"heading": "Implantologie à Aubagne",
"tagline": "Des solutions modernes pour remplacer vos dents",
"image": {
"url": "/media/pages/42/hero.webp",
"alt": "Cabinet d'implantologie à Aubagne"
},
"video_url": null,
"cta_primary_text": "Prendre rendez-vous",
"cta_primary_url": "https://www.doctolib.fr/...",
"cta_secondary_text": "04 42 XX XX XX",
"cta_secondary_url": "tel:+33442XXXXXX",
"overlay_position": "left"
}
},
{
"id": 102,
"block_type": "text",
"position": 1,
"is_visible": true,
"content": {
"body": "<p>L'implantologie dentaire permet de remplacer...</p>"
}
// NOTE: body is HTML (not Markdown). Helios renders via sanitized dangerouslySetInnerHTML.
// Content is authored as Markdown in Aletheia /web/ editor, converted to HTML on save.
},
{
"id": 103,
"block_type": "cards_grid",
"position": 2,
"is_visible": true,
"content": {
"heading": "Nos traitements en implantologie",
"cards": [
{
"title": "Remplacer une dent",
"excerpt": "L'implant unitaire est la solution...",
"image": { "url": "...", "alt": "..." },
"url": "/implant-dentaire-aubagne/remplacer-une-dent/"
}
]
}
}
]
}
}
Key decisions:
- url is the canonical front-end path for the page, built server-side from template + slug (+ the practice's city_name for service pages, e.g. /implant-dentaire-aubagne/). Helios consumes it directly for the sitemap and JSON-LD; it matches seo.canonical_url for most templates. Always present (PageDetailSerializer.get_url → build_page_url), never null.
- Blocks are returned pre-ordered by position. Next.js renders them sequentially.
- Images are returned with resolved URLs (not file IDs). Aletheia handles the URL generation.
- Every image object is { url, alt } (decorative?: boolean on editor-supplied
block images — see below). Helios's next/image derives its own responsive
srcset and negotiates WebP/AVIF on the fly from the source URL, so Aletheia
ships no pre-baked srcset / blur_placeholder (see §7, 2026-06-01).
- related_pages was dropped from the page payload 2026-06-01 — Helios never read it (the Page.related_pages M2M still exists model-side; it just isn't serialized).
- featured_image (blog pages) is an image object { url, alt } or null — the same shape as every other image. It now resolves from a MediaFile library reference (Page.featured_image_media) via build_image_object. It was previously a bare ImageField that serialized to a string URL — a latent mismatch with Helios's ImageData | null type; converging it onto the FK fixed the shape (image-picker convergence, see §7, 2026-06-01).
ContentBlock content shapes by block_type¶
Each block_type has a specific JSON shape for its content field. Helios renders each block type using the appropriate component.
Cross-repo invariant —
block_typeis a shared vocabulary, and Helios'sBlockRendererswitch fails open: an unknownblock_typerenders nothing, silently, with no error or telemetry. A new block type must therefore ship with its paired HeliosBlockRenderercase in the same coordinated change — never add one here first and expect Helios to no-op gracefully.
hero — Hero banner with image, CTA buttons, and text overlay
{
"heading": "Bienvenue au cabinet",
"tagline": "Votre sourire, notre engagement",
"image": { "url": "/media/...", "alt": "..." },
"video_url": null,
"cta_primary_text": "Prendre rendez-vous",
"cta_primary_url": "https://www.doctolib.fr/...",
"cta_secondary_text": "04 42 XX XX XX",
"cta_secondary_url": "tel:+33...",
"overlay_position": "left"
}
overlay_position: "left", "right", or "center" — controls text alignment over the hero image
- image: optional { url, alt, decorative? }; null when no hero image is set. decorative: true (present only when set) marks the image as purely presentational — Helios renders it via <DecorativeImage> with alt="" + role="presentation". Emitted on editor-supplied block images (hero, text_media, gallery items, cards_grid cards).
text — Rich text content (HTML)
body is sanitized HTML, not Markdown. Render with a sanitizer like DOMPurify.
text_media — Text with an image alongside
{
"heading": "Notre plateau technique",
"body": "<p>HTML content...</p>",
"image": { "url": "/media/...", "alt": "..." },
"media_position": "right"
}
media_position: "left" or "right" — which side the image appears on
cards_grid — Grid of linked cards
{
"heading": "Nos specialites",
"cards": [
{ "title": "Implantologie", "excerpt": "Description...", "image": { "url": "...", "alt": "..." }, "url": "/implant-dentaire-aubagne/" }
]
}
cta — Call to action banner
{
"heading": "Besoin d'un rendez-vous ?",
"body": "Description text",
"primary_text": "Reserver en ligne",
"primary_url": "https://...",
"secondary_text": "Appeler le cabinet",
"secondary_url": "tel:+33..."
}
faq — Frequently asked questions (accordion)
{
"heading": "Questions frequentes",
"items": [
{ "question": "Est-ce douloureux ?", "answer": "Non, l'intervention se fait sous anesthesie..." }
]
}
stats — Key figures / statistics
{
"heading": "Le cabinet en chiffres",
"stats": [
{ "value": "20+", "label": "Annees d'experience", "icon": "bi-calendar" }
]
}
icon: Bootstrap Icons class name (e.g., bi-calendar, bi-people)
testimonials — Patient testimonials (reference block)
{
"heading": "Ce que disent nos patients",
"max_display": 3,
"treatment_filter": "Implant dentaire"
}
GET /sites/{domain}/testimonials/ (optionally filtered by treatment_type query param) and display up to max_display items.
before_after — Case study before/after gallery (reference block)
{
"heading": "Nos resultats en images",
"max_display": 4,
"service_category_filter": "implant-dentaire"
}
GET /sites/{domain}/case-studies/ (optionally filtered by the service_category query param) and displays up to max_display items. The service_category_filter value is a service-page slug (matched against CaseStudy.service_page.slug); the block key + query-param names are unchanged, but the value is no longer a taxonomy slug (e.g. implant-dentaire, not implantologie). The per-case service_category_name field was dropped (Helios never read it) — cases carry no service identifier in the payload, since the fetch is already service-filtered.
team_grid — Practitioner grid (reference block)
GET /sites/{domain}/team/ and displays either all members (show_all: true) or up to max_display members.
gallery — Image gallery / carousel
{
"heading": "Visite du cabinet",
"images": [
{ "url": "/media/...", "alt": "Accueil", "caption": "Notre espace d'accueil" }
]
}
map — Practice location map with access info (reference block)
{
"heading": "Comment nous trouver",
"show_access_info": true,
"show_transit": true,
"show_parking": true
}
GET /sites/{domain}/practice/ for address, coordinates, transit, parking info. The boolean flags control which sections to display.
related_services — Links to related service pages
{
"heading": "Decouvrez aussi",
"page_slugs": ["implant-dentaire-aubagne", "orthodontie-aubagne"],
"max_display": 3
}
max_display items.
video — Embedded video
{
"heading": "Decouvrez notre cabinet",
"url": "https://www.youtube.com/watch?v=...",
"poster": { "url": "/media/..." },
"caption": "Visite virtuelle",
"autoplay": false
}
poster: thumbnail image shown before playback
- autoplay: whether to auto-start (muted) on viewport enter
quote — Blockquote / testimonial highlight
{
"text": "Choisir de proteger ses dents...",
"author": "Dr Andre Guigue",
"role": "Fondateur du cabinet"
}
sub_pages_grid — Auto-derived links to sibling pages (data-bound)
{
"heading": "En savoir plus",
"subheading": "Découvrez nos engagements et notre plateau technique.",
"parent_template": "cabinet",
"max_display": 8,
"include_self": false,
"pages": [
{ "slug": "notre-philosophie", "title": "Notre philosophie", "excerpt": "...", "url": "/cabinet/notre-philosophie/" },
{ "slug": "nos-technologies", "title": "Nos technologies", "excerpt": "...", "url": "/cabinet/nos-technologies/" }
]
}
heading, subheading, parent_template, max_display, include_self). The API joins sibling Page rows at read time and appends pages: [...].
- Sibling rule: same template as the block's host page (or parent_template if set), status=published, active=true. The host page is excluded unless include_self=true.
- Per-practice override (slug match on the active practice) wins over the shared row. Hidden pages drop out once the per-practice PageVisibility flag (Pass 3 / N8) is wired in.
equipment_showcase — Practice equipment cards (data-bound)
{
"heading": "Notre plateau technique",
"subheading": "Les outils que nous utilisons au quotidien.",
"equipment_types": ["cbct", "intraoral_scanner", "laser"],
"show_only_with_photo": false,
"max_display": 6,
"equipment": [
{
"equipment_type": "cbct",
"equipment_type_label": "CBCT",
"manufacturer": "Planmeca",
"model_name": "ProMax 3D",
"photo": { "url": "/media/...", "alt": "Planmeca ProMax 3D" }
}
]
}
PracticeEquipment rows for the active practice and appends equipment: [...].
- Filters: status=active, optional equipment_types whitelist, optional show_only_with_photo, optional max_display.
- Photo is the standard { url, alt } image object. When the equipment has no photo the photo key is null.
cabinet_gallery — Practice room gallery (data-bound)
{
"heading": "Nos salles",
"subheading": "",
"group_by": "room_family",
"display_style": "grid",
"show_only_with_photo": true,
"max_display": 12,
"rooms": [
{
"name": "Salle de soins 1",
"room_type": "treatment_room",
"room_type_label": "Salle de soins",
"family_label": "Zones cliniques",
"description": "Salle équipée d'un fauteuil Sirona Intego.",
"photo": { "url": "/media/...", "alt": "Salle de soins 1" }
}
]
}
PracticeRoom rows for the active practice and appends rooms: [...].
- Filters: status=active, is_website_visible=true, optional show_only_with_photo (default true), optional max_display.
- Each room exposes its room_type_label (display string) and its family_label (one of Zones cliniques, Zones de support, Zones d'accueil des patients). When group_by="room_family" Helios groups rooms under the family labels.
- Photo is the standard { url, alt } image object; null when the room has no photo (and show_only_with_photo=false).
contact_form — Contact form with optional map
{
"heading": "Contactez-nous",
"body": "Description text above the form",
"show_phone_field": true,
"show_map": true,
"success_message": "Votre message a bien ete envoye."
}
body: optional descriptive text rendered above the form
- show_phone_field: whether to include the phone number input
- show_map: whether to render a map alongside the form (using practice coordinates)
- Form submits to POST /sites/{domain}/contact/
3.3 Practice Data — GET /sites/{domain}/practice/¶
{
"data": {
"name": "Cabinet Dentaire d'Aubagne",
"address": {
"line1": "123 Avenue de la République",
"line2": null,
"postal_code": "13400",
"city": "Aubagne",
"country": "FR",
"latitude": 43.2927,
"longitude": 5.5668
},
"phone": "+33442XXXXXX",
"email": "contact@cabinet-aubagne.fr",
"whatsapp": "+33612345678",
"google_business_profile_url": "https://g.page/...",
"social": {
"facebook": "https://facebook.com/...",
"instagram": "https://instagram.com/...",
"linkedin": null,
"tiktok": null
},
"hours": {
"regular": [
{ "day": 1, "opens": "09:00", "closes": "19:00" },
{ "day": 2, "opens": "09:00", "closes": "19:00" },
{ "day": 6, "opens": "09:00", "closes": "12:00" }
],
"holidays": [
{ "date": "2026-12-25", "is_closed": true },
{ "date": "2026-07-14", "opens": "09:00", "closes": "12:00" }
]
},
"access": {
"parking_type": "public",
"parking_address": "Parking Centre-Ville",
"has_elevator": true,
"is_handicap_accessible": true,
"transit_stations": [{ "name": "Aubagne Gare", "lines": ["TER"] }],
"access_info": "2ème étage, ascenseur"
},
"booking": {
"is_bookable": true,
"doctolib_url": "https://www.doctolib.fr/..."
}
}
}
- The
paymentblock (accepts_*,regulation_sector,third_party_payer) andemergency_phonewere dropped 2026-06-01 — Helios reads onlysocial/access/booking/hours/addressfrom this payload (see §7). The underlying Practice fields are unchanged.
3.4 Team — GET /sites/{domain}/team/¶
Ordering: Manual-sorted members appear first (by display_order), then alphabetical members (by last name). This list endpoint returns only members with show_on_website=true (active, non-expired contracts); the per-member detail endpoint below also serves hidden/departed members — see the visible notes after the example.
{
"data": [
{
"slug": "dr-jean-dupont",
"title": "Dr",
"first_name": "Jean",
"last_name": "Dupont",
"photo": {
"url": "/media/dentists/dupont/portrait.webp",
"alt": "Dr Jean Dupont"
},
"specialty": "Chirurgien-dentiste",
"skills": [
{ "name": "Implantologie", "type": "subspeciality" },
{ "name": "Chirurgie guidée", "type": "procedure" }
],
"languages": ["fr", "en"],
"description": "Bio courte pour la grille...",
"booking_url": "https://www.doctolib.fr/...",
"is_bookable": true,
"training": [
{ "title": "DU Implantologie", "establishment": "Université Paris V", "year": 2015 }
],
"visible": true
}
]
}
visibleis always present and is the discriminant Helios narrows on (Practitioner | PractitionerHidden). On this list endpoint every returned member hasvisible: true(the queryset already filters toshow_on_website=true,active=true, and a non-pastend_date— seeTeamListView). The full profile fields (specialty,skills,languages,description,booking_url,is_bookable,training) accompanyvisible: true.GET /sites/{domain}/team/{slug}/(member detail) is the only placevisible: falseappears. It returns any contract for the slug — including departed/hidden practitioners — so a fiche URL keeps resolving after departure. A non-visible member returns the minimal fallback shape only:Helios renders a departure page from it; the full profile fields are absent. (See{ "slug": "dr-jean-dupont", "title": "Dr", "first_name": "Jean", "last_name": "Dupont", "photo": { "url": "...", "alt": "..." }, "visible": false }TeamMemberSerializer/TeamDetailView.)- Ordering is applied server-side (manual members by
display_order, then alphabetical) — thedisplay_order/sort_modevalues and the heavywork_schedulearray were dropped from the payload 2026-06-01; Helios renders members in received order and never read any of them (see §7). The DentistContract fields are unchanged.
3.5 Navigation Tree — GET /sites/{domain}/nav/¶
{
"data": {
"main": [
{ "label": "Le Cabinet", "url": "/cabinet/", "children": [
{ "label": "Notre philosophie", "url": "/cabinet/notre-philosophie/" },
{ "label": "Nos technologies", "url": "/cabinet/nos-technologies/" },
{ "label": "Notre charte qualité", "url": "/cabinet/charte-qualite/" },
{ "label": "Tarifs et remboursements", "url": "/cabinet/tarifs/" },
{ "label": "Accès et informations pratiques", "url": "/cabinet/acces-informations/" }
]},
{ "label": "L'Équipe", "url": "/equipe/" },
{ "label": "Votre Besoin", "url": "/votre-besoin/", "children": [
{ "label": "Embellir mon sourire", "url": "/votre-besoin/embellir-mon-sourire/" },
{ "label": "Remplacer des dents", "url": "/votre-besoin/remplacer-des-dents/" }
]},
{ "label": "Nos Soins", "url": "#", "children": [
{ "label": "Implantologie", "url": "/implant-dentaire-aubagne/", "children": [
{ "label": "Remplacer une dent", "url": "/implant-dentaire-aubagne/remplacer-une-dent/" }
]}
]},
{ "label": "Résultats", "url": "/resultats/" },
{ "label": "Contact", "url": "/contact/" }
],
"cta": {
"label": "Prendre RDV",
"url": "https://www.doctolib.fr/...",
"phone": "+33442XXXXXX"
}
}
}
Response structure:
| Key | Type | Description |
|---|---|---|
main |
NavItem[] |
Ordered top-level navigation. Each item has label, url, and optional children (recursive NavItem[]). |
cta |
object |
Primary call-to-action button (booking). label: button text, url: Doctolib booking link, phone: practice phone number. |
url: "#" — non-navigating menu grouping:
Top-level items whose url is "#" are pure menu groupings (currently only "Nos Soins"). They have children but no destination page of their own. Helios renders them as disclosure buttons (<button type="button" aria-haspopup="true">) that open the dropdown without navigating.
Dynamic hub dropdowns (Le Cabinet, Votre Besoin, Nos Soins):
Children for the Le Cabinet, Votre Besoin, and Nos Soins groupings are derived from the practice's visible Page rows (cabinet / votre_besoin / service_hub+service_detail templates) at API time — fully page-driven, no separate taxonomy. Per-practice overrides win over the shared library at matching slugs; pages a practice has hidden via PageVisibility are dropped. For Nos Soins, service_detail pages nest under their service_hub parent and each level is ordered by the frozen NOS_SOINS_NAV_SLUG_ORDER (slug-alphabetical tiebreak); labels use menu_label. The Le Cabinet / Votre Besoin hub links are always present — if no sub-pages are visible, the entry has no children key and renders as a flat link.
3.6 ISR Revalidation Webhook — POST /webhooks/revalidate/ (Aletheia → Helios)¶
This is the one endpoint hosted by Helios (a Next.js route handler at
src/app/webhooks/revalidate/route.ts) and called by Aletheia. Helios pages
are statically generated, so a CMS edit only appears in production once the
affected cache tags are busted. Aletheia's apps/websites/signals.py (model
saves) and apps/websites/views_cms.py (CMS image swaps) funnel through
apps/websites/revalidation.py → the websites.trigger_revalidation Celery task,
which POSTs here.
Auth — shared secret in the X-Revalidate-Secret request header, compared
in constant time (crypto.timingSafeEqual) against Helios's REVALIDATION_SECRET
env var (= Aletheia's HELIOS_REVALIDATION_SECRET; provisioned per-env via Aether).
The secret is not in the body.
Request
{
"practice_domain": "cabinet-dentaire-aubagne.fr",
"tags": ["page:implant-dentaire-aubagne", "nav:cabinet-dentaire-aubagne.fr"],
"reason": "Page published: Implantologie à Aubagne"
}
Responses — 200 {"revalidated": true, "tags": [...]} (Helios called
revalidateTag() for each tag); 401 {"error": "Unauthorized"} (missing/wrong
secret — Aletheia does not retry); 400 (non-JSON body or missing/empty
tags). Transient 5xx / connection failures are retried by the Celery task
(max 3). The webhook is a silent no-op when HELIOS_REVALIDATION_URL is unset.
Tag vocabulary (must match the fetch tags in Helios src/lib/api.ts):
| Tag | Busts | Emitted on |
|---|---|---|
page:<slug> |
one page (getPage) |
Page / ContentBlock save |
nav:<domain> |
page list + nav tree (getPageList / getNavigation) |
Page save |
practice:<domain> |
site config + practice data (getSiteConfig / getPracticeData) |
SiteConfig / Practice save |
cases:<domain> |
case studies (getCaseStudies) |
CaseStudy save |
testimonials:<domain> |
testimonials (getTestimonials) |
Testimonial save |
blog:<domain> |
blog index (getBlogPosts) |
blog_post Page publish |
team |
team grid + member pages (getTeam / getTeamMember) — global (dentists are shared across practices, no practice FK) |
Dentist / DentistContract save |
site:<domain> |
every fetch for one tenant — the coarse lever for image swaps | per-practice image override/revert (one domain); shared library replace/reset (fan-out per active domain) |
Tags are domain-scoped (…:<domain>), not pk-scoped — every Helios fetch
keys off domain. site:<domain> is carried by every Helios fetch so a single
revalidateTag('site:<domain>') re-renders a whole tenant; it is used for image
swaps, where the changed asset may surface on any page and there is no
key→referencing-pages map yet (per-referencing-page targeting is a later
refinement).
3.7 Contact Form — POST /sites/{domain}/contact/¶
{
"name": "Marie Martin",
"email": "marie@example.com",
"phone": "+33612345678",
"message": "Je souhaite prendre rendez-vous...",
"hcaptcha_token": "..."
}
Response: 201 Created with { "success": true } or 400 with { "errors": { ... } }.
4. Media URL Patterns¶
All media served via Cloudflare CDN. Aletheia generates URLs, Next.js consumes them.
/media/
├── practices/{practice_id}/
│ ├── logo.svg
│ ├── favicon.ico
│ └── hero/
│ ├── hero-640.webp
│ ├── hero-1280.webp
│ └── hero-1920.webp
├── dentists/{dentist_id}/
│ ├── portrait-300.webp
│ └── portrait-600.webp
├── pages/{page_id}/
│ ├── {block_id}-{filename}-{size}.webp
│ └── ...
├── case-studies/{id}/
│ ├── before-400.webp
│ ├── before-800.webp
│ ├── after-400.webp
│ └── after-800.webp
└── videos/
├── {id}.mp4
└── {id}.webm
5. Model Structure Suggestion for apps/websites/¶
This is the recommended model structure for Aletheia, based on all analysis work (data audit, visual design, content editing decisions, spec). Aletheia implementation may deviate — update §7 Changelog if the API shape changes.
5.1 Core Models¶
# apps/websites/models.py
class SiteConfig(AuditModel, SoftDeleteModel):
"""Per-practice website configuration. One per practice."""
practice = models.OneToOneField("practices.Practice", on_delete=models.CASCADE,
related_name="site_config")
domain = models.CharField(max_length=253, unique=True) # e.g., "cabinet-dentaire-aubagne.fr"
is_active = models.BooleanField(default=False)
# Theme — stored as OKLCH hue + chroma; full palette derived in CSS
primary_hue = models.FloatField(default=195) # 0-360
primary_chroma = models.FloatField(default=0.12) # 0-0.4
accent_hue = models.FloatField(default=70)
accent_chroma = models.FloatField(default=0.14)
# Branding — references into the shared MediaFile library (one picker UX,
# reusable rows). logo_url in §3.1 resolves from logo_media.file.url.
logo_media = models.ForeignKey("websites.MediaFile", on_delete=models.SET_NULL,
null=True, blank=True, related_name="+") # → logo_url
favicon_media = models.ForeignKey("websites.MediaFile", on_delete=models.SET_NULL,
null=True, blank=True, related_name="+") # CMS-only (favicon_url dropped)
# SEO
city_name = models.CharField(max_length=100) # "Aubagne" — for URL generation
meta_title_template = models.CharField(max_length=200,
default="{page_title} | {practice_name}")
meta_description_default = models.TextField(blank=True)
# Features
enabled_locales = models.JSONField(default=list) # ["fr"] or ["fr", "en"]
enabled_services = models.JSONField(default=list) # ["implantologie", "esthetique", ...]
class PageTemplate(models.TextChoices):
HOMEPAGE = "homepage"
SERVICE_HUB = "service_hub" # L1 — category hub
SERVICE_DETAIL = "service_detail" # L2 — individual service
PRACTITIONER = "practitioner"
TEAM = "team"
VOTRE_BESOIN = "votre_besoin" # patient-centric need page
CABINET = "cabinet" # static: philosophie, technologies, tarifs, acces
RESULTS = "results" # cas cliniques + testimonials
BLOG_LIST = "blog_list"
BLOG_POST = "blog_post"
CONTACT = "contact"
LEGAL = "legal" # mentions legales, confidentialite
CUSTOM = "custom" # freeform
class PageStatus(models.TextChoices):
DRAFT = "draft"
REVIEW = "review"
PUBLISHED = "published"
class Page(AuditModel, SoftDeleteModel):
"""A website page. practice=NULL means default (shared content library)."""
practice = models.ForeignKey("practices.Practice", on_delete=models.CASCADE,
null=True, blank=True, related_name="website_pages")
template = models.CharField(max_length=30, choices=PageTemplate.choices)
slug = models.SlugField(max_length=200)
title = models.CharField(max_length=200)
status = models.CharField(max_length=10, choices=PageStatus.choices, default="draft")
published_at = models.DateTimeField(null=True, blank=True)
# Blog featured image — a reference into the shared MediaFile library (one
# picker UX, reusable rows). Serialized as featured_image: {url, alt} | null.
featured_image_media = models.ForeignKey("websites.MediaFile", on_delete=models.SET_NULL,
null=True, blank=True, related_name="+") # → featured_image
# SEO overrides (optional — defaults derived from title + SiteConfig template)
meta_title = models.CharField(max_length=200, blank=True)
meta_description = models.TextField(blank=True)
# Cross-linking
related_pages = models.ManyToManyField("self", symmetrical=False, blank=True,
related_name="referenced_by")
# Service taxonomy — a service page IS its own node (ServiceCategory was
# collapsed into this self-FK). service_hub has parent=None; service_detail
# points at its hub. Set on shared (practice IS NULL) pages only; a
# per-practice override inherits structure from its shared twin by slug.
parent = models.ForeignKey("self", on_delete=models.PROTECT,
null=True, blank=True, related_name="children")
# Token-free nav label for service pages (was ServiceCategory.name). Nav uses
# this when set, else resolve_tokens(title).
menu_label = models.CharField(max_length=100, blank=True, default="")
class Meta:
unique_together = ["practice", "slug"]
# Restores the global-slug guarantee the retired ServiceCategory.slug
# gave: unique_together does not bind shared rows (NULL practices are
# distinct in Postgres).
constraints = [UniqueConstraint(fields=["slug"], condition=Q(practice__isnull=True),
name="uniq_shared_page_slug")]
ordering = ["title"]
def save(self, *args, **kwargs):
# Shared service pages auto-derive hub/detail from parent presence, so
# the CMS exposes a single "Service" choice. loaddata (raw save) bypasses
# this; per-practice rows keep their inherited template.
...
class BlockType(models.TextChoices):
HERO = "hero"
TEXT = "text"
TEXT_MEDIA = "text_media"
CARDS_GRID = "cards_grid"
SUB_PAGES_GRID = "sub_pages_grid"
EQUIPMENT_SHOWCASE = "equipment_showcase"
CABINET_GALLERY = "cabinet_gallery"
CTA = "cta"
FAQ = "faq"
TESTIMONIALS = "testimonials"
BEFORE_AFTER = "before_after"
STATS = "stats"
TEAM_GRID = "team_grid"
GALLERY = "gallery"
MAP = "map"
RELATED_SERVICES = "related_services"
VIDEO = "video"
QUOTE = "quote"
CONTACT_FORM = "contact_form"
class ContentBlock(AuditModel, SoftDeleteModel):
"""Flexible content block. JSON content validated per block_type."""
page = models.ForeignKey(Page, on_delete=models.CASCADE, related_name="blocks")
block_type = models.CharField(max_length=30, choices=BlockType.choices)
position = models.PositiveIntegerField(default=0)
content = models.JSONField(default=dict)
is_visible = models.BooleanField(default=True)
class Meta:
ordering = ["position"]
5.2 Service Taxonomy¶
The service taxonomy is not a separate model — a service page is its own
taxonomy node via Page.parent (see §5.1). The retired ServiceCategory mapped
1:1 onto its Page, so the convergence collapsed it:
ServiceCategory.url_prefix→ the hub page's slug (e.g.implant-dentaire); URLs are unchanged.ServiceCategory.name→Page.menu_label(token-free nav label).ServiceCategory.display_order→ a frozen code constantNOS_SOINS_NAV_SLUG_ORDER(serializers.py), likeCABINET_NAV_SLUG_ORDER/VOTRE_BESOIN_NAV_SLUG_ORDER.ServiceCategory.icon→ dropped (never read end-to-end).- The taxonomy
slug(e.g.implantologie) → dropped; its only consumer (the case-study filter) moved onto a realCaseStudy.service_pageFK.
5.3 Case Studies & Testimonials¶
class CaseStudy(AuditModel, SoftDeleteModel):
"""Before/after case linked to treatment types. Cross-page: displayed on
/resultats/cas-cliniques/ AND individual service detail pages."""
practice = models.ForeignKey("practices.Practice", on_delete=models.CASCADE)
title = models.CharField(max_length=200)
# Before/after — references into the shared MediaFile library (one picker UX,
# reusable rows). Serialized as before_image / after_image: {url, alt} | null.
before_image_media = models.ForeignKey("websites.MediaFile", on_delete=models.SET_NULL,
null=True, blank=True, related_name="+") # → before_image
after_image_media = models.ForeignKey("websites.MediaFile", on_delete=models.SET_NULL,
null=True, blank=True, related_name="+") # → after_image
caption = models.TextField(blank=True)
# The service page this case illustrates (was a slug match against the
# retired ServiceCategory; now a real FK).
service_page = models.ForeignKey("Page", on_delete=models.SET_NULL,
null=True, blank=True, related_name="case_studies")
is_published = models.BooleanField(default=False)
class Meta:
ordering = ["-created_at"]
class Testimonial(AuditModel, SoftDeleteModel):
"""Anonymized patient testimonial."""
practice = models.ForeignKey("practices.Practice", on_delete=models.CASCADE)
quote = models.TextField()
author = models.CharField(max_length=100) # "Marie D." (anonymized)
rating = models.PositiveSmallIntegerField(default=5,
validators=[MinValueValidator(1), MaxValueValidator(5)])
treatment_type = models.CharField(max_length=100, blank=True) # "Implant dentaire"
is_published = models.BooleanField(default=False)
class Meta:
ordering = ["-created_at"]
5.4 Media & Contact¶
class MediaFile(AuditModel, SoftDeleteModel):
"""Uploaded media (images, video, documents) served to Helios."""
practice = models.ForeignKey("practices.Practice", on_delete=models.CASCADE)
file = models.FileField(upload_to="media/")
media_type = models.CharField(max_length=10,
choices=[("image", "Image"), ("video", "Video"), ("document", "Document")])
alt_text = models.CharField(max_length=200, blank=True)
caption = models.CharField(max_length=300, blank=True)
# Images are served as { url, alt }; Helios's next/image derives its own
# responsive srcset on the fly (no pre-baked variants — see §7, 2026-06-01).
class Meta:
ordering = ["-created_at"]
class ContactSubmission(AuditModel):
"""Contact form submission. Not soft-deletable (legal record)."""
practice = models.ForeignKey("practices.Practice", on_delete=models.CASCADE)
name = models.CharField(max_length=100)
email = models.EmailField()
phone = models.CharField(max_length=20, blank=True)
message = models.TextField()
is_read = models.BooleanField(default=False)
# No soft delete — submissions are a legal/audit record
5.5 Aletheia Core Model Changes (from data audit)¶
These are small migrations on existing models, not new models:
# apps/practices/models.py — ADD:
# Branding/website images are MediaFile library references (image-picker
# convergence, 2026-06-01); the {{practice.X.url}} tokens resolving them are
# unchanged. logo_media + hero_image_media / cabinet_hero_image_media /
# reception_photo_media / exterior_photo_media are FKs into the library.
logo_media = models.ForeignKey("websites.MediaFile", on_delete=models.SET_NULL,
null=True, blank=True, related_name="+")
whatsapp_number = PhoneNumberField(null=True, blank=True)
google_business_profile_url = models.URLField(blank=True)
# Extend equipment_type choices: add laser, microscope, cerec, meopa, piezo, guided_surgery
# apps/dentists/models.py — ADD:
# Portrait is a MediaFile library reference (one picker UX, reusable rows;
# image-picker convergence, 2026-06-01). Serialized as team[].photo: {url,alt} | null.
# A dentist has no practice FK, so a portrait is a shared-scope (practice=NULL) row.
photo_media = models.ForeignKey("websites.MediaFile", on_delete=models.SET_NULL,
null=True, blank=True, related_name="+") # → team[].photo
title = models.CharField(max_length=10, blank=True) # "Dr", "Prof"
slug = models.SlugField(max_length=100, blank=True) # auto-generated from name
# DentistContract — ADD:
display_order = models.PositiveIntegerField(default=0)
6. Caching & Performance Contract¶
All tags are domain-scoped (…:<domain>), plus the per-tenant site:<domain>
carried by every fetch. See §3.6 for the full tag vocabulary.
| Data | Cache tag(s) | Revalidation |
|---|---|---|
| SiteConfig | practice:<domain>, site:<domain> |
On SiteConfig save → revalidate tag |
| Page + blocks | page:<slug>, site:<domain> |
On publish / block save → revalidate tag |
| Practice data | practice:<domain>, site:<domain> |
On Practice save → revalidate tag |
| Team | team, site:<domain> |
On Dentist/DentistContract save → revalidate |
| Nav tree | nav:<domain>, site:<domain> |
On Page change → revalidate |
| Blog index | blog:<domain>, site:<domain> |
On blog_post Page publish → revalidate |
| Case studies | cases:<domain>, site:<domain> |
On CaseStudy save → revalidate |
| Testimonials | testimonials:<domain>, site:<domain> |
On Testimonial save → revalidate |
| Image swap | site:<domain> |
On per-practice override/revert (one domain) or shared library replace/reset (fan-out per active domain) |
All revalidation flows through the webhook (§3.6). Aletheia fires the webhook on model-save signals + CMS image-swap views (via the Celery task, to avoid blocking the request).
7. Changelog¶
Track spec-breaking changes here. Format: date, what changed, why, frontend impact.
2026-03-27 — text.body is HTML, not Markdown
Content blocks with block_type "text" and "text_media" return body as HTML
(e.g., <p><strong>...</strong></p>), not raw Markdown. Content is authored
as Markdown in Aletheia /web/ editor, converted to HTML on save.
Frontend impact: Helios renders body with sanitized dangerouslySetInnerHTML,
not a Markdown parser.
2026-03-27 — stats block field rename: items → stats (done)
Stats block content uses { stats: [...] }, not { items: [...] }.
Frontend impact: none (fixed before first Helios consume).
2026-03-28 — theme.heading_font and theme.body_font added to SiteConfig
Two new string fields in the theme object: heading_font and body_font.
Values are Google Font family names (e.g., "DM Serif Display", "Inter").
Defaults: heading_font="DM Serif Display", body_font="DM Sans".
Frontend impact: Helios should load these via next/font/google and apply
to --font-heading / --font-body CSS variables.
2026-03-30 — All 16 block content shapes documented in §3.2
Added "ContentBlock content shapes by block_type" section with JSON
examples and field descriptions for all 16 block types. Identifies
reference blocks (testimonials, before_after, team_grid, map) that
require additional API calls to fetch data.
Frontend impact: Helios can now implement all block renderers from
this single document without inspecting Aletheia schemas.
2026-03-30 — Team endpoint: show_on_website filter + sort_mode ordering
GET /sites/{domain}/team/ now filters by show_on_website=true (new field
on DentistContract). Ordering: manual-sorted members first (by
display_order), then alphabetical (by last name). New field sort_mode
("manual" or "alpha") returned per team member.
Frontend impact: Helios should display team members in the order returned
by the API (no client-side sorting needed). The sort_mode field is
informational — no frontend logic change required.
2026-04-03 — practice_code and umami_website_id added to SiteConfig
Two new fields at the top level of GET /sites/{domain}/config/:
- practice_code (string): internal code from Practice model (e.g., "CDA", "VSM")
- umami_website_id (string): per-domain Umami tracking ID (empty if not set)
Frontend impact: Helios should read umami_website_id from config response
instead of NEXT_PUBLIC_UMAMI_WEBSITE_ID env var (fall back to env var if empty).
practice_code is informational, available for any frontend logic that needs it.
2026-04-15 — portal object added to nav response
GET /sites/{domain}/nav/ now returns a third key "portal" alongside "main"
and "cta". Shape: { label, url, enabled }. Currently enabled=false (hardcoded).
Frontend impact: Helios should render a "Mon Espace" element in the header
(near the CTA, not in main nav). While enabled=false, show as disabled/greyed
with "Bientot disponible" tooltip. No routing needed until portal is built.
2026-05-12 — build_website seeding + URL resolution changes
Three related changes from the shared-content-library work:
1. New page template "espace_patient" (URL prefix /espace-patient/).
Placeholder template for now — content will be fleshed out from the
content guidelines in a follow-up. Currently seeded as a shared page
and may be returned by GET /sites/{domain}/pages/ for any practice.
Frontend impact: Helios should treat it as a renderable CMS page
(do not add to EXCLUDED_TEMPLATES). Generic block rendering is fine
until a dedicated layout lands; expect the template to gain
bespoke blocks/sections later.
2. city_name is slugified (not lowercased) when used in URLs.
Service-hub URLs (build_page_url) and the "Nos Soins" nav children
now use slugify(site_config.city_name) instead of city_name.lower().
No-op for ASCII single-word cities ("Aubagne"); for cities with
accents or spaces ("Aix-en-Provence", "Saint-Étienne") the URL slug
now strips accents and collapses spaces to hyphens.
Frontend impact: none for existing practices. New practices: trust
the URL strings returned by the API; do not re-derive from city_name
client-side.
3. Service-hub slug lookup accepts the city-suffixed URL.
Hubs are stored at slug "implant-dentaire" but served at
"/implant-dentaire-{city}/". GET /sites/{domain}/pages/{slug}/ now
strips the trailing -{city_slug} and retries for the service_hub
template, matching the URL pattern Helios's catch-all generates.
Frontend impact: none — Helios's existing "last URL segment as slug"
extraction continues to work.
Also: page title and excerpt are now token-resolved server-side in
PageList / PageDetail responses (same {{practice.X}} / {{site.X}}
tokens already applied to block content). Behavior-compatible — Helios
receives resolved strings either way.
2026-05-21 — nav response: portal removed; charte-qualité added; "Le Cabinet" now 5-child
Three nav reconciliation changes from the shared-base buildout
(roadmap/done/websites-shared-base-buildout.md, decisions N4/C2/N6):
1. Drop the "portal" / "Mon Espace" placeholder.
GET /sites/{domain}/nav/ no longer returns a "portal" key. The
/mon-espace/ surface is covered by /espace-patient/ (Optional
protected shared page); the placeholder was a divergent header
element with no consumer route.
Frontend impact: Helios drops PortalButton / MobilePortalButton.
Navigation type loses the `portal` field. The 2026-04-15 changelog
entry above is superseded.
2. Restore "Notre charte qualité" to the "Le Cabinet" dropdown.
"Le Cabinet" children now have 5 entries (was 4): Notre philosophie,
Nos technologies, Notre charte qualité, Tarifs, Accès & informations.
The charte page was already seeded by build_website; only the menu
was missing it.
Frontend impact: none — Helios renders the dropdown from the API
payload directly.
3. Helios divergent fallbacks dropped.
Helios no longer ships hardcoded fallback navs (DEFAULT_NAV in
Nav.tsx, the inline array in Footer.tsx, MOCK_NAV in lib/mock-data.ts).
The `navigation` prop is now required on both Nav and Footer; the
layout passes the live API payload to both. The mock-data file is
deleted.
Frontend impact: any new consumer must obtain `navigation` from
`getNavigation(domain)`; there is no static fallback.
2026-05-21 — shared base + per-practice override semantics documented
No payload change. §2 of this contract now documents the page-resolution
rules that PageListView / PageDetailView have always followed: per-practice
row wins at a given slug, shared row otherwise; new shared pages propagate
automatically; overrides survive shared deletion; canonical slugs cannot
be renamed (enforced by Page.clean against RESERVED_PAGE_SLUGS).
Frontend impact: none — describes existing server behavior.
2026-05-21 — Two new BlockTypes: sub_pages_grid + equipment_showcase
Shared-base buildout decisions C9 + C3. Both are data-bound blocks: the
CMS stores config-only JSON, and the API injects the joined payload at
read time. Seed wires them into the cabinet hub and /cabinet/nos-technologies/
respectively, replacing hand-authored cards_grid blocks that had to be
re-edited on every shared-base change.
- sub_pages_grid: serializer queries sibling Page rows by template,
appends `pages: [{slug,title,excerpt,url}, ...]`. Per-practice override
wins on slug; PageVisibility (N8, Pass 3) will tighten the filter once
it ships.
- equipment_showcase: serializer joins PracticeEquipment for the active
practice, appends `equipment: [{equipment_type,equipment_type_label,
manufacturer,model_name,photo}, ...]`. Filters: status=active,
equipment_types whitelist, show_only_with_photo, max_display.
Frontend impact: Helios needs two new renderers (SubPagesGrid,
EquipmentShowcase). Until the components ship, falling back to a generic
cards-style renderer that consumes `pages` / `equipment` works — both
payloads carry titles, URLs/photos, and excerpts/labels.
2026-05-21 — Helios sub_pages_grid + equipment_showcase renderers shipped
No payload change. Closes the frontend half of the 2026-05-21 entry above:
the dedicated renderers landed in Helios (commit f670a81) under C11, so the
generic cards-style fallback is no longer in use.
2026-05-21 — nav response: Le Cabinet + Votre Besoin dropdowns now dynamic
per practice (N1 + N2).
GET /sites/{domain}/nav/ no longer hardcodes the "Le Cabinet" children.
The dropdown is built from visible `cabinet`-template Page rows
(per-practice override + shared library), filtered by PageVisibility
(Optional pages a practice has opted out of are dropped). The hub link
is always emitted; `children` is only present when at least one
sub-page is visible — when a practice hides every Optional cabinet
sub-page, "Le Cabinet" renders as a flat link to /cabinet/. Same rule
applies to "Votre Besoin" (visible `votre_besoin` Page rows, ordered
by the VOTRE_BESOIN_NAV_SLUG_ORDER constant).
Frontend impact: none — Helios already renders dropdowns directly
from the API payload. Labels match the practice's Page.title (so a
per-practice override that renames "Notre philosophie" propagates
to the menu).
2026-05-21 — New BlockType cabinet_gallery + /cabinet/visitez-le-cabinet/
page + Helios renderer (C1).
Shared-base buildout decision C1. cabinet_gallery is a data-bound block:
the CMS stores config-only JSON, the API injects the joined payload at
read time. Seed wires it into the new /cabinet/visitez-le-cabinet/ shared
page; new `PracticeRoom.photo` and `PracticeRoom.is_website_visible` model
fields drive the joined list.
- cabinet_gallery: serializer joins PracticeRoom rows for the active
practice where is_website_visible=true and status=active, appends
`rooms: [{name, room_type, room_type_label, family_label, description,
photo}, ...]`. Filters: optional `show_only_with_photo` (default true),
optional `max_display`. `family_label` is one of "Zones cliniques",
"Zones de support", "Zones d'accueil des patients" — Helios can group
on this when `group_by="room_family"`.
- /cabinet/visitez-le-cabinet/: new Optional shared page (template=cabinet).
Replaces the three retired stand-alone room pages from the original
plan (folded into a single gallery per the DROP list in
websites-shared-base-buildout.md).
Frontend impact: Helios renderer for `cabinet_gallery` shipped alongside
the API change (BlockRenderer.tsx). Practices fill rooms (with photos)
in Aletheia and they surface here automatically — zero per-page CMS
authoring.
2026-05-28 — PatientNeed model retired (see
roadmap/done/websites-retire-patient-need.md).
The PatientNeed model is dropped (DeleteModel migration). Its two original
responsibilities had both already been revoked: the nav-derivation rule was
inverted to gate on visible Page rows (2026-05-21/05-25), and the "Soins
associés" cross-links on votre-besoin pages were always hand-authored
cards_grid blocks, never resolved from the PatientNeed.service_pages M2M.
The model's last live consumer was the votre-besoin dropdown sort key, now
a frozen slug tuple (VOTRE_BESOIN_NAV_SLUG_ORDER) — the same nav-ordering
pattern as CABINET_NAV_SLUG_ORDER.
Frontend impact: none. The /sites/{domain}/nav/ payload is unchanged — the
"Votre Besoin" dropdown still emits the same 5 entries ({label, url}) in the
same canonical order, derived from visible votre_besoin Page rows.
2026-05-29 — ServiceCategory collapsed into the Page tree (see
roadmap/done/websites-service-category-page-tree-convergence.md).
The ServiceCategory model is dropped. A service page IS its own taxonomy node:
Page gains a `parent` self-FK (service_hub → no parent; service_detail → its
hub) + an optional token-free `menu_label`. `url_prefix` already equalled the
hub slug, so URLs are byte-identical; `display_order` became the frozen
`NOS_SOINS_NAV_SLUG_ORDER` code constant; `icon` and the `implantologie`-style
taxonomy slug were dropped. "Nos Soins" nav is now built from service Pages
(page-driven, like Le Cabinet / Votre Besoin), so an orphan node can no longer
surface a 404 menu link.
Spec-breaking, but no site is live → coordinated cutover, no transition window.
Verified against the Helios `develop` checkout — **no Helios code change is
required**:
- **Case-study payload**: the per-case `service_category_name` is dropped, with
no replacement field. Helios never read it (`getCaseStudies`'s return type
reads only `before_image` / `after_image` / `caption` / `treatment_url`), and
cases are already returned service-filtered via `?service_category=`, so a
per-row service identifier is redundant. No-op for the frontend.
- **before_after `service_category_filter`**: same block key, still passed to
`GET /case-studies/?service_category=<value>` (both the block key and the
query-param name are unchanged). `BlockRenderer` reads the block value and
forwards it verbatim, so the value shift to a service-page slug
(`implant-dentaire`, never the old `implantologie`) flows through with no
code change; no old taxonomy slug is hardcoded anywhere in Helios.
- **Nav / page URLs**: unchanged by construction (hub slug = old url_prefix;
detail = `/{hub-slug}-{city}/{detail-slug}/`). Helios switches on the
`service_hub` / `service_detail` template values (both kept) and consumes nav
`url` fields; it never composes service URLs from `url_prefix`. No
`service_category` field ever appeared in nav or page payloads.
- **enabled_services**: values are now hub-page slugs; Helios only declares the
field as a type, never branches on its values — no impact.
Frontend impact: none (verified). The only required action is operational:
deploy + reseed each environment from the regenerated fixture
(`restore_website_seed --clean-full`) and re-verify a service hub/detail page,
the "Nos Soins" dropdown, and a before_after gallery on staging.
2026-06-01 — image objects trimmed to { url, alt } (variant pipeline retired)
(see roadmap/done/websites-retire-image-variant-pipeline.md).
Every image object across the API — page/block images, team photos,
case-study before/after, equipment + room photos — now returns only
`{ url, alt }`. The pre-baked `srcset` map and base64 `blur_placeholder`
(LQIP) are gone, along with the `variants` / `source_hash` columns and the
Celery/signal machinery that produced them.
Why: Helios is self-hosted Next.js standalone with image optimization on, so
`next/image` generates its own responsive `srcset` and negotiates WebP/AVIF
on the fly from the source URL. Verified field-by-field against the Helios
`develop` checkout (`9908fe4`): no component ever read `srcset` or
`blur_placeholder` (declared in `src/lib/types.ts` but unused), so the
pre-baked payload was dead weight.
Spec-breaking but harmless; no site is live → coordinated cutover, no
transition window. Frontend impact: none (verified). Helios's `ImageData`
drops the now-unused `srcset?` / `blur_placeholder?` fields; `decorative?`
stays. If a `placeholder="blur"` consumer ever appears, a single base64
string per image can be re-added cheaply (no columns/signals/Celery).
2026-06-01 — payloads trimmed to what Helios consumes; treatment_url + decorative
(see roadmap/done/websites-trim-api-payloads.md).
Re-verified field-by-field against the Helios `develop` checkout (`0695431`).
Each dropped field had 0 grep hits in `src/app`+`src/components`+`src/lib`, or
appeared only as a `src/lib/types.ts` type declaration that no component reads.
Dropped (over-fetch — Helios never read them):
- SiteConfig: `practice_id`, `practice_code` (keys off `domain`), `favicon_url`
(favicon hardcoded), `enabled_locales` / `enabled_services` (no i18n; services
nav is page-driven), `theme.heading_font` / `theme.body_font` (fonts hardcoded
to `--font-dm-sans` / `-serif`), `seo.meta_title_template` (server already
applies it in the page `seo`).
- Page: `related_pages` (+ the `RelatedPageSerializer`). The `Page.related_pages`
M2M is unchanged model-side; it is simply no longer serialized.
- Practice: the `payment` block (`accepts_*`, `regulation_sector`,
`third_party_payer`) and `emergency_phone`.
- Team: `work_schedule` (a heavy per-practitioner nested array shipped on every
team-page load), `sort_mode`, `display_order` — ordering is still applied
server-side; only the redundant payload is gone.
- CaseStudy: `id`, `title`, `is_published` (the queryset already filters
`is_published=True`, so it was always `true`).
- Testimonial: `id`.
Added:
- CaseStudy `treatment_url`: the public URL of the case's linked `service_page`
(the treatment page it illustrates), or `null` when unlinked. Lets Helios
deep-link a before/after case to its service page. Migration-free — resolves
the existing `CaseStudy.service_page` FK; the `/case-studies/` view now passes
`site_config` to the serializer so service-hub URLs are city-suffixed.
- `decorative?: boolean` on editor-supplied block images (`hero`, `text_media`,
`gallery` items, `cards_grid` cards). Present only when set; marks an image as
purely presentational so Helios renders it via `<DecorativeImage>` (`alt=""` +
`role="presentation"`) per SEO-AUDIT-2026-05 Helios-010. As part of this,
`cards_grid` card images were reshaped from a legacy flat URL string into the
`{ url, alt, decorative? }` object the schema + Helios `ServiceCard` expect.
Spec-breaking but harmless (removing fields a typed client never reads + adding
two). No site is live → coordinated cutover, no transition window. Helios change
(separate commit): `src/lib/types.ts` drops the now-unsent fields, and the
before/after renderer consumes `treatment_url`. Operational action: deploy +
reseed each environment and re-verify a team page, a before_after gallery (incl.
the treatment link), and a config-driven page on staging.
2026-06-01 — SiteConfig branding now references the MediaFile library (internal)
(see roadmap/done/websites-image-picker-convergence.md, Phase P1 first slice).
`SiteConfig.logo` / `favicon` ImageFields became `logo_media` / `favicon_media`
FKs into the shared `MediaFile` library, so the one library picker drives them
and a logo is a reusable library row (eventually a `practice=NULL` curated
default) instead of a per-config upload.
API impact: none. `config.logo_url` is unchanged — still an absolute URL string
or `null`, now resolved from `logo_media.file.url`. `favicon_url` was already
dropped (favicon hardcoded in Helios), so the favicon FK is CMS-only. Verified
against the Helios `develop` checkout: the only `logo_url` reader
(`generatePracticeSchema` → JSON-LD `image`) sees an identical value.
Migration carries any existing `logo` / `favicon` upload into a `MediaFile` row
in place (no on-disk copy). No live tenant → apply the migration + reseed; no
transition window.
2026-06-01 — Practice branding/website images now reference the MediaFile library (internal)
(see roadmap/done/websites-image-picker-convergence.md, Phase P1 Practice slice).
`Practice.logo` + the four website-content ImageFields (`hero_image`,
`cabinet_hero_image`, `reception_photo`, `exterior_photo`) became `<name>_media`
FKs into the shared `MediaFile` library, so the one library picker drives them
and each becomes a reusable library row (eventually a `practice=NULL` curated
default) instead of a per-practice upload.
API impact: none. These fields are never sent on the wire — they are surfaced
only through shared-content `{{practice.X.url}}` tokens and the OG-card render.
The token names are unchanged (`{{practice.hero_image.url}}` etc.); the
resolver now reads `<name>_media.file.url`, so seeded content and the fixture
need no edit. Verified against the Helios `develop` checkout.
Migration carries any existing upload into a `MediaFile` row in place (no
on-disk copy). No live tenant → apply the migration + reseed; no transition
window.
2026-06-01 — Page.featured_image now references the MediaFile library (+ shape fix)
(see roadmap/done/websites-image-picker-convergence.md, Phase P1 Page slice).
`Page.featured_image` (the blog featured image) became `featured_image_media`,
an FK into the shared `MediaFile` library, so the one library picker drives it
and a featured image becomes a reusable library row (eventually a `practice=NULL`
curated default) instead of a per-page upload.
API impact — shape FIX (no transition window needed; no live tenant). On the
wire `featured_image` is now an image object `{ url, alt }` or `null`, resolved
via `build_image_object` from `featured_image_media.file` — matching Helios's
documented `ImageData | null` type for `page.featured_image` (consumed by the
blog post header and `BlogPostCard`). It was previously a bare `ImageField` that
DRF serialized to a *string* URL — a latent mismatch with what Helios reads
(`page.featured_image.url` / `.alt`); the FK conversion is the moment it is
corrected. Verified against the Helios `develop` checkout.
Seed fixture regenerated (`shared_content.yaml` holds Page rows): every shared
page's `featured_image: ''` became `featured_image_media: null` (no shared page
carries a featured image). Migration carries any existing upload into a
`MediaFile` row in place (no on-disk copy).
2026-06-01 — CaseStudy before/after now reference the MediaFile library (internal)
(see roadmap/done/websites-image-picker-convergence.md, Phase P1 CaseStudy slice).
`CaseStudy.before_image` / `after_image` ImageFields became `before_image_media`
/ `after_image_media` FKs into the shared `MediaFile` library, so the one library
picker drives them and a before/after photo is a reusable library row (eventually
a `practice=NULL` curated default) instead of a per-case upload. The pair stays
required at the form level (`CaseStudyForm`); the DB FK is nullable (`SET_NULL`).
API impact: none. On the wire `before_image` / `after_image` are unchanged —
still a `{ url, alt }` image object or `null` (they were already
`SerializerMethodField`s resolving via `build_image_object`); only the source
moved from `<field>.file` to `<field>_media.file`. Verified against the Helios
`develop` checkout: `getCaseStudies` reads `before_image` / `after_image` /
`caption` / `treatment_url` and sees identical shapes.
CaseStudy is not in `shared_content.yaml`, so no fixture regen. Migration carries
any existing upload into a `MediaFile` row in place (tag `treatment`; no on-disk
copy). No live tenant → apply the migration + reseed; no transition window.
2026-06-01 — Dentist portrait now references the MediaFile library (internal)
(see roadmap/done/websites-image-picker-convergence.md, Phase P1 Dentist slice —
the last P1 field; the dentists app is the first adopter of the websites
`MediaLibrarySelect` widget).
`Dentist.photo` ImageField became `photo_media`, an FK into the shared `MediaFile`
library, so the one library picker drives it and a portrait is a reusable library
row (eventually a `practice=NULL` curated default) instead of a per-dentist upload.
A dentist has no practice FK (it works at practices through DentistContract,
possibly several), so a portrait is a person-level, *shared-scope* asset: the
migration files existing uploads as `practice=NULL` library rows (tag `team`) and
the CMS picker opens on the shared library.
API impact: none. On the wire `team[].photo` is unchanged — still a `{ url, alt }`
image object or `null` (`TeamMemberSerializer` already resolved it via
`build_image_object`); only the source moved from `dentist.photo` to
`dentist.photo_media.file`. Verified against the Helios `develop` checkout: the
`/equipe/` pages and `TeamCard` read `member.photo.url` / `member.photo.alt` and
see identical shapes.
Dentist is not in `shared_content.yaml`, so no fixture regen. Migration carries any
existing upload into a `MediaFile` row in place (no on-disk copy). No live tenant →
apply the migration + reseed; no transition window.
2026-06-02 — block images may store a library `ref` / `media_id` (internal, no wire change)
(see roadmap/in-progress/websites-platform-image-library.md, C10 step 1 — the
curated platform-image-library reference mechanism.)
The `hero` / `text_media` / `cards_grid` image object may now carry, alongside the
legacy `url`, one of two optional authoring keys: `ref` (a `MediaFile.library_key` —
a curated platform-library slot) or `media_id` (a direct `MediaFile` pk). A new
practice-aware resolver in `ContentBlockSerializer` turns either into the standard
`{ url, alt }` at read time: `ref` prefers the practice's own override row over the
shared `practice=NULL` default (per-practice swap without a page fork); `media_id`
resolves that exact row; an unresolved/not-yet-seeded slot becomes `null` (Helios's
existing graceful fallback — plain text / gradient).
API impact: none. `ref` / `media_id` are server-side authoring keys — Helios never
sees them; on the wire every block image stays `{ url, alt } | null` (`decorative?`
preserved). No new endpoint, no shape change. The new `MediaFile.library_key` column
is internal. No live tenant → migrate + reseed; no transition window.
2026-06-02 — empty `hero` blocks resolve a fallback image (internal, no wire change)
(see roadmap/in-progress/websites-platform-image-library.md, C10 — generic hero
fallbacks.)
A `hero` block whose image resolves to nothing (no `ref` / `media_id` / `url`, or a
not-yet-seeded `ref`) now falls back server-side instead of emitting `null`: first to
the page's **parent hero** (a `service_detail` borrows its hub's banner — a different
image from its own body illustration, so no on-page repeat), then — for a parentless
page (the utility pages) — to a **generic library fallback** picked by `Page.template`
(the 3 seeded `ai_hero_*_fallback` images). Resolution stays practice-aware (an
inherited hub hero honours a per-practice override) and is read-only — nothing is
stamped onto the page.
API impact: none. A hero image that used to come back `null` (gradient) may now come
back `{ url, alt }`; the wire shape is unchanged and Helios keeps its gradient when
even the fallback is unseeded. No endpoint or schema change.
2026-06-02 — platform-library images served at content-addressed URLs (internal, no wire change)
(see roadmap/in-progress/websites-platform-image-library.md, C10 step 9a — cache
propagation.)
Curated platform-library files (`practice=NULL` `MediaFile` rows seeded from
`apps/websites/content/library/`) are now stored and served at
`/media/library/<library_key>.<hash8>.<ext>`, where `<hash8>` is the first 8 hex of the
bytes' SHA-256 — instead of the former stable `/media/library/<library_key>.<ext>`. The
URL is stable while the bytes are stable and changes the instant they change, so a
curated re-render or CMS shared-replace busts the browser / `next/image` optimizer /
`/api/media` proxy caches that key on the URL (the proxy's `immutable, max-age=1y` is now
correct rather than a trap). The `library_key` and the repo / `manifest.generated.json`
filenames stay **hashless** — only the served path carries the digest.
API impact: none. Every image object stays `{ url, alt } | null`; `url` was always an
opaque absolute string and Helios treats it as one (its `/api/media` proxy + `next/image`
handle any path). The one consumer-side caveat: nothing may assume the hashless
`library/<key>.<ext>` name — in particular a future build-time `public/media` snapshot
must key on the full served path. NB: this fixes only the *image-bytes* cache; making a
change appear on a **statically-rendered prod** page still needs on-demand revalidation
(the half-built `POST /webhooks/revalidate/`, C10 step 9b — not yet wired in Helios).
2026-06-03 — ISR revalidation webhook finished + reframed as a Helios receiver
(see roadmap/in-progress/websites-helios-revalidation-webhook.md, C10 step 9b).
Completes the half-built Aletheia→Helios on-demand revalidation pipeline so a CMS
content change re-renders the affected pages on a statically-generated prod site
without a rebuild — the page-HTML half of the 9a content-addressing fix.
- Reframed (§2 + §3.6): `POST /webhooks/revalidate/` is a **Helios-hosted** route
handler (`src/app/webhooks/revalidate/route.ts`) that **Aletheia calls** — it was
mis-filed under Aletheia's endpoint table. Not part of `/api/v1/websites/`.
- Auth moved to a header: shared secret in `X-Revalidate-Secret`, constant-time
compared against Helios's `REVALIDATION_SECRET` (= Aletheia's
`HELIOS_REVALIDATION_SECRET`). Dropped from the JSON body. 401 (no retry) on
bad/missing secret, 400 on bad payload, 200 on success.
- Tag namespace reconciled to **domain-scoped** on the Aletheia side (`signals.py`):
`nav` / `practice` / `cases` / `testimonials` now emit `…:<domain>` to match
Helios's fetch tags (were `…:<pk>`, which matched nothing); `page:<slug>` + `team`
already aligned; `blog:<domain>` added on `blog_post` publish.
- New `site:<domain>` tag carried by **every** Helios fetch (`src/lib/api.ts`), so a
single `revalidateTag('site:<domain>')` re-renders a whole tenant. Used for image
swaps (per-practice override/revert → that one domain; shared library replace/reset
→ fan-out per active domain), which previously fired nothing. Coarse-first;
per-referencing-page targeting is a later refinement.
Frontend impact: new route handler + one extra tag per fetch (additive — existing
tags unchanged). Operational: Aether provisions `HELIOS_REVALIDATION_URL`
(→ `https://<helios-host>/webhooks/revalidate/`) + a real shared secret on both sides
per env. No live tenant → clean cutover, no transition window.
2026-06-03 — documented `Page.url` (§3.2) + Team `visible` (§3.4) — already emitted, no wire change
Two fields the API has always sent are now in the spec examples, closing a
Helios-audit gap where they were consumed but undocumented:
- `url` (top-level on the page payload) — canonical front-end path from
`PageDetailSerializer.get_url` → `build_page_url`; always present. Helios
uses it for sitemap + JSON-LD.
- `visible` (boolean) on every team member — Helios's `Practitioner |
PractitionerHidden` discriminant. `true` on the `/team/` list (pre-filtered
to active + `show_on_website`); `false` only on the `/team/{slug}/` detail
endpoint, which serves departed/hidden members with a minimal fallback shape
(now documented).
Also added a cross-repo note in §3.2: a new `block_type` must ship with its
paired Helios `BlockRenderer` case — Helios fails open (renders nothing) on
unknown types.
Frontend impact: none — clarification only; the wire payload is unchanged.