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¶
- Developer hits a constraint in Aletheia that affects what the API returns.
- Add a dated entry to §7 Changelog explaining: what changed, why, and what frontend impact.
- If the change affects visual_design.md or spec_helios.md, update those too with a cross-reference.
- Next time you context-switch to Next.js work, read the Changelog first.
Source of Truth¶
| Concern | Source of truth |
|---|---|
| What data exists and how it's stored | Aletheia codebase (apps/) |
| What the API returns | This document |
| What the frontend needs to display | visual_design.md + spec_helios.md |
| ContentBlock JSON schemas | Aletheia codebase (validated there), documented here |
2. API Overview¶
Base URL: Configured via ALETHEIA_API_URL environment variable.
- Local dev: http://localhost:8000/api/v1/websites/
- Server (Option A): http://aletheia-{env}-web:8000/api/v1/websites/ (Docker network)
- Server (Options B/C): http://10.0.0.1/api/v1/websites/ (vRack private network)
- See infra_contract.md §2-3 for full setup details.
Auth: None needed (private network, read-only for Next.js). Write endpoints (contact form) use CSRF or API key.
Format: JSON. All responses use consistent envelope:
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 is sanitized HTML, not Markdown. Render with a sanitizer like DOMPurify.
text_media — Text with an image alongside
{
"heading": "Notre plateau technique",
"body": "<p>HTML content...</p>",
"image": { "url": "/media/...", "alt": "..." },
"media_position": "right"
}
media_position: "left" or "right" — which side the image appears on
cards_grid — Grid of linked cards
{
"heading": "Nos specialites",
"cards": [
{ "title": "Implantologie", "excerpt": "Description...", "image": { "url": "...", "alt": "..." }, "url": "/implant-dentaire-aubagne/" }
]
}
cta — Call to action banner
{
"heading": "Besoin d'un rendez-vous ?",
"body": "Description text",
"primary_text": "Reserver en ligne",
"primary_url": "https://...",
"secondary_text": "Appeler le cabinet",
"secondary_url": "tel:+33..."
}
faq — Frequently asked questions (accordion)
{
"heading": "Questions frequentes",
"items": [
{ "question": "Est-ce douloureux ?", "answer": "Non, l'intervention se fait sous anesthesie..." }
]
}
stats — Key figures / statistics
{
"heading": "Le cabinet en chiffres",
"stats": [
{ "value": "20+", "label": "Annees d'experience", "icon": "bi-calendar" }
]
}
icon: Bootstrap Icons class name (e.g., bi-calendar, bi-people)
testimonials — Patient testimonials (reference block)
{
"heading": "Ce que disent nos patients",
"max_display": 3,
"treatment_filter": "Implant dentaire"
}
GET /sites/{domain}/testimonials/ (optionally filtered by treatment_type query param) and display up to max_display items.
before_after — Case study before/after gallery (reference block)
{
"heading": "Nos resultats en images",
"max_display": 4,
"service_category_filter": "implantologie"
}
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)
GET /sites/{domain}/team/ and displays either all members (show_all: true) or up to max_display members.
gallery — Image gallery / carousel
{
"heading": "Visite du cabinet",
"images": [
{ "url": "/media/...", "alt": "Accueil", "caption": "Notre espace d'accueil" }
]
}
map — Practice location map with access info (reference block)
{
"heading": "Comment nous trouver",
"show_access_info": true,
"show_transit": true,
"show_parking": true
}
GET /sites/{domain}/practice/ for address, coordinates, transit, parking info. The boolean flags control which sections to display.
related_services — Links to related service pages
{
"heading": "Decouvrez aussi",
"page_slugs": ["implant-dentaire-aubagne", "orthodontie-aubagne"],
"max_display": 3
}
max_display items.
video — Embedded video
{
"heading": "Decouvrez notre cabinet",
"url": "https://www.youtube.com/watch?v=...",
"poster": { "url": "/media/..." },
"caption": "Visite virtuelle",
"autoplay": false
}
poster: thumbnail image shown before playback
- autoplay: whether to auto-start (muted) on viewport enter
quote — Blockquote / testimonial highlight
{
"text": "Choisir de proteger ses dents...",
"author": "Dr Andre Guigue",
"role": "Fondateur du cabinet"
}
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.