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, ServiceCategory, PatientNeed, 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" }
}

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
POST /webhooks/revalidate/ ISR revalidation trigger (Aletheia → Next.js) Aletheia on publish

3. Payload Shapes

3.1 SiteConfig — GET /sites/{domain}/config/

{
  "data": {
    "practice_id": 4,
    "practice_code": "CDA",
    "domain": "cabinet-dentaire-aubagne.fr",
    "practice_name": "Cabinet Dentaire d'Aubagne",
    "umami_website_id": "2c6ff41e-2b5d-4163-939e-46523c23b066",
    "logo_url": "/media/practices/aubagne/logo.svg",
    "favicon_url": "/media/practices/aubagne/favicon.ico",
    "theme": {
      "primary_hue": 195,
      "primary_chroma": 0.12,
      "accent_hue": 70,
      "accent_chroma": 0.14,
      "heading_font": "DM Serif Display",
      "body_font": "DM Sans"
    },
    "enabled_locales": ["fr"],
    "enabled_services": ["implantologie", "esthetique", "parodontologie"],
    "seo": {
      "city_name": "Aubagne",
      "meta_title_template": "{page_title} | Cabinet Dentaire 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. - heading_font and body_font are Google Font family names. Next.js loads them via next/font/google and applies them to the appropriate CSS variables.

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",
    "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",
            "srcset": {
              "640w": "/media/pages/42/hero-640.webp",
              "1280w": "/media/pages/42/hero-1280.webp",
              "1920w": "/media/pages/42/hero-1920.webp"
            },
            "blur_placeholder": "data:image/webp;base64,..."
          },
          "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": "...", "srcset": { ... } },
              "url": "/implant-dentaire-aubagne/remplacer-une-dent/"
            }
          ]
        }
      }
    ],
    "related_pages": [
      { "slug": "esthetique-dentaire-aubagne", "title": "Esthétique dentaire", "url": "/esthetique-dentaire-aubagne/" }
    ]
  }
}

Key decisions: - 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. - srcset variants are pre-generated by Aletheia's image pipeline. - blur_placeholder is base64-inlined for LQIP (Low Quality Image Placeholder). - related_pages is a flat list from the M2M relationship.

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.

hero — Hero banner with image, CTA buttons, and text overlay

{
  "heading": "Bienvenue au cabinet",
  "tagline": "Votre sourire, notre engagement",
  "image": { "url": "/media/...", "alt": "...", "srcset": {}, "blur_placeholder": "data:..." },
  "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, with srcset/blur when processed by image pipeline

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": "implantologie"
}
- Reference block: Helios fetches from GET /sites/{domain}/case-studies/ (optionally filtered by service_category query param) and displays up to max_display items.

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"
}

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",
    "emergency_phone": "+33442XXXXXX",
    "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"
    },
    "payment": {
      "accepts_carte_vitale": true,
      "accepts_check": true,
      "accepts_cash": true,
      "accepts_credit_card": true,
      "regulation_sector": 1,
      "third_party_payer": true
    },
    "booking": {
      "is_bookable": true,
      "doctolib_url": "https://www.doctolib.fr/..."
    }
  }
}

3.4 Team — GET /sites/{domain}/team/

Ordering: Manual-sorted members appear first (by display_order), then alphabetical members (by last name). Only members with show_on_website=true are returned.

{
  "data": [
    {
      "slug": "dr-jean-dupont",
      "title": "Dr",
      "first_name": "Jean",
      "last_name": "Dupont",
      "photo": {
        "url": "/media/dentists/dupont/portrait.webp",
        "srcset": { "300w": "...", "600w": "..." },
        "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,
      "display_order": 1,
      "sort_mode": "manual",
      "training": [
        { "title": "DU Implantologie", "establishment": "Université Paris V", "year": 2015 }
      ],
      "work_schedule": [
        { "day": 1, "valid_from": null, "valid_to": null },
        { "day": 3, "valid_from": null, "valid_to": null }
      ]
    }
  ]
}

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": "Tarifs", "url": "/cabinet/tarifs/" },
        { "label": "Accès & informations", "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"
    },
    "portal": {
      "label": "Mon Espace",
      "url": "/mon-espace/",
      "enabled": false
    }
  }
}

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.
portal object Patient portal link. label: button text, url: portal route, enabled: whether the portal is live.

portal — Patient portal placeholder:

Helios should render this as a separate element in the header (e.g. next to the CTA), not inside the main navigation list. Behavior depends on enabled:

  • enabled: false (current) — render as a disabled/greyed-out button or a "Bientot disponible" tooltip. Do not link to /mon-espace/.
  • enabled: true (future) — render as a clickable link to /mon-espace/ which will host the authenticated patient portal.

The enabled flag is currently hardcoded to false. It will later be driven by a SiteConfig field when the portal is implemented per-practice.

3.6 ISR Revalidation Webhook — POST /webhooks/revalidate/ (Aletheia → Next.js)

{
  "secret": "REVALIDATION_SECRET",
  "practice_domain": "cabinet-dentaire-aubagne.fr",
  "tags": ["page:implant-dentaire-aubagne", "practice:aubagne"],
  "reason": "Page published: Implantologie à Aubagne"
}

Next.js calls revalidateTag() for each tag. Tags follow the pattern: page:{slug}, practice:{id}, team, nav.

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
    logo = models.ImageField(upload_to="sites/logos/", null=True, blank=True)
    favicon = models.ImageField(upload_to="sites/favicons/", null=True, blank=True)

    # 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)

    # 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 link (for service_hub and service_detail pages)
    service_category = models.ForeignKey("ServiceCategory", on_delete=models.SET_NULL,
                                          null=True, blank=True)

    class Meta:
        unique_together = ["practice", "slug"]
        ordering = ["title"]


class BlockType(models.TextChoices):
    HERO = "hero"
    TEXT = "text"
    TEXT_MEDIA = "text_media"
    CARDS_GRID = "cards_grid"
    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

class ServiceCategory(AuditModel, SoftDeleteModel):
    """Service category for navigation, URL generation, and cross-linking.
    E.g., Implantologie, Esthétique dentaire, Parodontologie."""
    name = models.CharField(max_length=100)             # "Implantologie"
    slug = models.SlugField(max_length=100)             # "implantologie"
    url_prefix = models.CharField(max_length=100)       # "implant-dentaire" (for city-suffixed URLs)
    icon = models.CharField(max_length=50, blank=True)  # icon identifier for nav/cards
    display_order = models.PositiveIntegerField(default=0)
    parent = models.ForeignKey("self", on_delete=models.CASCADE,
                                null=True, blank=True, related_name="children")

    class Meta:
        ordering = ["display_order"]

5.3 Patient Needs ("Votre Besoin")

class PatientNeed(AuditModel, SoftDeleteModel):
    """Patient-centric need mapping. E.g., 'Embellir mon sourire' → Facettes, Blanchiment.
    Used for nav dropdowns AND 'Votre Besoin' page content."""
    name = models.CharField(max_length=100)             # "Embellir mon sourire"
    slug = models.SlugField(max_length=100)
    icon = models.CharField(max_length=50, blank=True)
    description = models.TextField(blank=True)          # patient-friendly explanation
    display_order = models.PositiveIntegerField(default=0)
    # M2M to service pages this need links to
    service_pages = models.ManyToManyField(Page, blank=True, related_name="patient_needs")

    class Meta:
        ordering = ["display_order"]

5.4 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_image = models.ImageField(upload_to="case-studies/")
    after_image = models.ImageField(upload_to="case-studies/")
    caption = models.TextField(blank=True)
    service_category = models.ForeignKey(ServiceCategory, on_delete=models.SET_NULL,
                                          null=True, blank=True)
    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.5 Media & Contact

class MediaFile(AuditModel, SoftDeleteModel):
    """Uploaded media with auto-generated responsive variants."""
    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)

    # Auto-generated by image pipeline (populated after upload via signal/Celery)
    variants = models.JSONField(default=dict)  # {"640w": "/path.webp", "1280w": "...", "blur": "data:..."}

    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.6 Aletheia Core Model Changes (from data audit)

These are small migrations on existing models, not new models:

# apps/practices/models.py — ADD:
logo = models.ImageField(upload_to="practices/logos/", null=True, blank=True)
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:
photo = models.ImageField(upload_to="dentists/photos/", null=True, blank=True)
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

Data Cache strategy Revalidation
SiteConfig use cache with tag practice:{id} On SiteConfig save → revalidate tag
Page + blocks use cache with tag page:{slug} On publish → revalidate tag
Practice data use cache with tag practice:{id} On Practice save → revalidate tag
Team use cache with tag team On Dentist/DentistContract save → revalidate
Nav tree use cache with tag nav:{practice_id} On Page/ServiceCategory/PatientNeed change → revalidate
Case studies use cache with tag cases:{practice_id} On CaseStudy save → revalidate
Testimonials use cache with tag testimonials:{practice_id} On Testimonial save → revalidate

All revalidation flows through the webhook (§3.6). Aletheia fires the webhook on model save signals (via 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.