Pilote Orléans · configuration, pas un fork · aucun lieu inventé

Livrable 10

Modèle de données

PostgreSQL. UUID (gen_random_uuid()) pour toute entité exposée. Pas d'ID séquentiel dans les URL. Timestamps timestamptz UTC. city_id sur tout objet localisable.

Deux couches géo : voir document 17. Les colonnes lat / lng existent toujours. La colonne geog (PostGIS) est nullable et absente du chemin sandbox tant que l'extension n'est pas vérifiée au boot.

Territoire

countries (code PK)
regions (id, country_code, name)
departments (id, region_id, code, name)
cities (id, department_id nullable, slug UK, name, timezone, status, centroid_lat, centroid_lng, bbox_geojson, dna jsonb, published_dna_version)
city_dna_versions (id, city_id, version, document jsonb, published_at, actor_id)
zones (id, city_id, slug, name, kind, geometry_geojson, centroid_lat, centroid_lng, status)
  kind: neighborhood | street_corridor | square | campus | waterfront | ski | port | commercial | cultural | tourist | other
streets (id, city_id, name, geometry_geojson nullable)   -- odonyme, pas de page publique au MVP

Pas de table districts. Un quartier est une zone de kind neighborhood. Le mot affiché (« quartier », « secteur ») vient du vocabulaire DNA. Une seule URL : /{city}/zones/{slug}.

Hiérarchie administrative : EXTERNAL DATA REQUIRED (contours officiels). Une ville peut exister sans region/department remplis.

Lieux et temps

venues (
  id, city_id, zone_id nullable, slug, name, category_id,
  lat, lng, address_line, postcode,
  price_range nullable,  -- enum unknown|low|mid|high, jamais inventé
  status,                -- draft|published|suspended|closed
  closed_until timestamptz nullable,
  last_verified_at, created_at, updated_at
)
venue_categories (id, slug, parent_id nullable)          -- taxonomie globale
city_categories (city_id, category_id, label, priority)  -- vocabulaire local
venue_hours (
  id, venue_id, weekday 0-6, opens_local time, closes_local time
  -- 0 = lundi … 6 = dimanche. ISO DNA 1–7 se mappe par weekday = iso - 1.
  -- Ne pas utiliser Date.getDay() (0 = dimanche) sans conversion.
  -- si closes <= opens : passe minuit
)
venue_hour_exceptions (id, venue_id, starts_on, ends_on, opens_local nullable, closes_local nullable, closed bool, note)
venue_features (venue_id, key, value)   -- seulement si sourcé : terrace, music, step_free, ...
venue_sources (id, venue_id, source_id, external_id, payload_hash, fetched_at)
venue_members (venue_id, user_id, role, created_at)
venue_claims (id, venue_id, user_id, status, method, decided_by, decided_at)

closed_until et exception closed gagnent sur l'horaire hebdomadaire. Un import plus ancien ne les écrase pas (voir conflits).

Événements, offres, updates

events (
  id, city_id, venue_id nullable, zone_id nullable, slug,
  title, description, starts_at, ends_at,   -- ends_at peut être J+1, toujours > starts_at
  price_cents nullable, price_unknown bool, currency,
  age_min nullable, status,                 -- draft|scheduled|published|cancelled|expired
  organizer_name, source_id, last_verified_at
)
-- published ou scheduled ⇒ venue_id ou zone_id non nul. Un draft peut n'avoir ni l'un ni l'autre.
offers (
  id, city_id, venue_id, title, description, terms,
  valid_from, valid_until, promo_code nullable,
  redemption_limit nullable, status, alcohol_related bool default false
)
live_updates (
  id, city_id, venue_id, kind, body,
  valid_from, valid_until, status, author_id,
  alcohol_related bool default false
)

alcohol_related = true ⇒ non public tant que legal_gates.alcohol_ads est off. Ce n'est pas le module nightlife. LEGAL REVIEW REQUIRED.

Confiance — trois axes, pas un badge

source_type:     BUSINESS_VERIFIED | OFFICIAL_SOURCE | AUTHORIZED_API | CITY_EDITOR
               | USER_REPORT | OPEN_DATA | PARTNER | UNKNOWN
verification_status: DECLARED_BY_BUSINESS | VERIFIED | UNVERIFIED
temporal_status: LIVE | RECENT | STALE | EXPIRED | CLOSED_OVERRIDE | UNKNOWN

BUSINESS_VERIFIED = la source est un membre d'un claim approuvé. Ce n'est pas VERIFIED. VERIFIED = last_verified_at dans verified_days. CLOSED_OVERRIDE = closed_until dans le futur : le lieu n'est pas ouvert, l'enregistrement n'est pas expiré.

data_sources (id, name, source_type, terms_note, created_at)
verifications (id, entity_type, entity_id, method, status, actor_id, created_at)
entity_matches (id, entity_type, left_id, right_id, match_score, status)
  status: auto_linked | human_review | rejected | merged
ingestion_records (
  id, source_id, external_id, stage, payload_hash, city_id nullable
)
  stage: raw | normalized | matched | duplicate | needs_review | verified | published | rejected
editorial_picks (id, city_id, entity_type, entity_id, position, starts_at, ends_at)
  -- rail « Sélection locale ». Absent du score organique.

Pas de score de confiance affiché tel quel. Colonnes internes freshness_score et confidence_score sont des politiques (0–100 entiers), documentées, recalculées, jamais vendues comme probabilité scientifique.

Champs communs sur venue / event / offer / live_update (table ou colonnes) :

source_id, created_at, updated_at, valid_from, valid_until, last_verified_at, verification_method, verification_status, temporal_status, freshness_score, confidence_score.

Identité et social léger

users / sessions          -- Better Auth quand les comptes sont allumés ; ne pas réinventer un store parallèle
profiles (user_id PK, display_name, locale, personalization_enabled bool default false)
favorites (user_id, entity_type, entity_id, created_at)
follows (user_id, entity_type, entity_id, created_at)     -- venue|event|zone|category
notification_prefs (user_id, channel, kind, enabled)
notifications (id, user_id, kind, payload, created_at, read_at)
consent_records (id, user_id nullable, subject_key, purpose, granted, created_at, policy_version)

Purposes séparés : cookies · geolocation · marketing · notifications.

Plans de soirée (modèle dès MVP, UI P1)

night_plans (id, city_id, owner_id nullable, status, constraints jsonb, visibility, expires_at)
night_plan_steps (id, plan_id, position, venue_id nullable, event_id nullable, kind, starts_at, note)
night_plan_members / night_plan_votes / night_plan_suggestions   -- prévues, non exposées MVP

owner_id nul ⇒ expires_at obligatoire (défaut proposé : création + 72 h), puis suppression. Pas de plan anonyme durable.

Pro, argent, ads (schéma tôt, usage P1)

billing_accounts (id, name, kind)   -- kind: venue | group. GROUP n'est pas un rôle RBAC.
billing_account_members (account_id, user_id, role)
plans (id, code, name, amount_cents nullable, currency, interval, active)
  -- codes: FREE | PRO | PREMIUM | GROUP | ENTERPRISE
  -- amount null = prix non décidé. Jamais de constante dans le code.
subscriptions (id, billing_account_id, plan_id, status, provider, provider_ref)
invoices (id, subscription_id, amount_cents, status)
campaigns (id, venue_id, objective, status, budget_cents nullable, starts_at, ends_at, zone_id nullable)
campaign_creatives (id, campaign_id, body, status)
campaign_metrics (campaign_id, day, impressions, clicks)  -- seulement des counts réels

Billing au MVP : tables présentes ou migration P1 avant tout encaissement. Pas d'encaissement sans Stripe branché et webhooks vérifiés. THIRD-PARTY DEPENDENCY.

Contenu, signaux, audit

articles (id, city_id, kind, slug, status, body, published_at)
article_links (article_id, entity_type, entity_id)
reviews          -- table P2, pas d'UI MVP
reports (id, entity_type, entity_id, reason, status, reporter_id nullable)
analytics_events (id, occurred_at, name, city_id, props jsonb)
  -- props sans IP brute, sans lat/lng précis. Rétention : voir privacy.
search_gaps (id, city_id, query_normalized, occurred_on, count)
audit_logs (id, actor_id, action, entity_type, entity_id, before jsonb, after jsonb, at)
feature_flags (key PK, default_bool, description)
city_flag_overrides (city_id, key, value bool)

analytics_events est un journal produit, pas un entrepôt infini. Agrégats quotidiens + rétention bornée. LEGAL REVIEW REQUIRED sur la durée.

Index (cible, à mesurer avant d'en ajouter d'autres)

  • Unique (city_id, slug) sur venues, events, zones, articles.
  • B-tree (city_id, starts_at) events ; (city_id, valid_until) offres et live_updates où status = published.
  • Index partiel « publics non expirés ».
  • GIST sur geog si PostGIS.
  • GIN sur tsvector si FTS disponible.
  • GIN trigram sur name si pg_trgm.

Pas de pgvector au MVP (pas d'embedding réel).

Décisions

D1 — Mono-schéma, ville = colonne

  • Choix : une base, city_id, pas un schéma par ville.
  • Alternatives : database-per-city (isolation forte, ops lourdes) ; schema-per-city.
  • Compromis : une fuite de filtre city_id mélange deux villes. Tests de scope obligatoires.
  • Pourquoi : Orléans puis N villes sans migration structurelle.

D2 — JSON pour la politique, tables pour les entités

DNA, contraintes de soirée, props analytics : jsonb. Lieux, events, zones : colonnes. Évite le blob impossible à requêter et le schéma rigide pour la nav.

D3 — Historique de source

Chaque fusion garde venue_sources. On ne « possède » pas une donnée tierce : terms_note rappelle la licence. EXTERNAL + revue licence avant import.

Flags

  • PostGIS, pg_trgm, unaccent : vérification au démarrage, pas un présupposé.
  • Contours INSEE / BAN : EXTERNAL DATA REQUIRED, licence à lire avant chargement.