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 | UNKNOWNBUSINESS_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
geogsi PostGIS. - GIN sur
tsvectorsi FTS disponible. - GIN trigram sur
namesi 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_idmé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.