Aller au contenu

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

  1. Developer hits a constraint in Aletheia that affects what the API returns.
  2. Add a dated entry to §7 Changelog explaining: what changed, why, and what frontend impact.
  3. If the change affects visual_design.md or spec_helios.md, update those too with a cross-reference.
  4. 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:

{
  "data": { ... },
  "meta": { "cached_at": "2026-03-27T10:00:00Z" }
}

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 by slug with 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() against apps.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_urlbuild_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 invariantblock_type is a shared vocabulary, and Helios's BlockRenderer switch fails open: an unknown block_type renders nothing, silently, with no error or telemetry. A new block type must therefore ship with its paired Helios BlockRenderer case 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": "<p>Content as <strong>HTML</strong>...</p>" }
- 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"
}
- Reference block: does not contain testimonial data inline. Helios should fetch testimonials from 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"
}
- Reference block: Helios fetches from 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)

{
  "heading": "Notre equipe",
  "show_all": false,
  "max_display": 6
}
- Reference block: Helios fetches from 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
}
- Reference block: Helios uses practice data from 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
}
- Helios fetches page metadata for each slug from the page list or individual page endpoints. Display up to 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/" }
  ]
}
- Data-bound block: the seed stores only config (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" }
    }
  ]
}
- Data-bound block: the seed stores only config. The API joins 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" }
    }
  ]
}
- Data-bound block: the seed stores only config. The API joins 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 payment block (accepts_*, regulation_sector, third_party_payer) and emergency_phone were dropped 2026-06-01 — Helios reads only social / access / booking / hours / address from 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
    }
  ]
}
  • visible is always present and is the discriminant Helios narrows on (Practitioner | PractitionerHidden). On this list endpoint every returned member has visible: true (the queryset already filters to show_on_website=true, active=true, and a non-past end_date — see TeamListView). The full profile fields (specialty, skills, languages, description, booking_url, is_bookable, training) accompany visible: true.
  • GET /sites/{domain}/team/{slug}/ (member detail) is the only place visible: false appears. 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:
    { "slug": "dr-jean-dupont", "title": "Dr", "first_name": "Jean", "last_name": "Dupont", "photo": { "url": "...", "alt": "..." }, "visible": false }
    
    Helios renders a departure page from it; the full profile fields are absent. (See TeamMemberSerializer / TeamDetailView.)
  • Ordering is applied server-side (manual members by display_order, then alphabetical) — the display_order / sort_mode values and the heavy work_schedule array 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

POST /webhooks/revalidate/
X-Revalidate-Secret: <shared secret>
Content-Type: application/json
{
  "practice_domain": "cabinet-dentaire-aubagne.fr",
  "tags": ["page:implant-dentaire-aubagne", "nav:cabinet-dentaire-aubagne.fr"],
  "reason": "Page published: Implantologie à Aubagne"
}

Responses200 {"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.namePage.menu_label (token-free nav label).
  • ServiceCategory.display_order → a frozen code constant NOS_SOINS_NAV_SLUG_ORDER (serializers.py), like CABINET_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 real CaseStudy.service_page FK.

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.