diff --git a/SQL/scheme.sql b/SQL/scheme.sql new file mode 100644 index 0000000..6643fd2 --- /dev/null +++ b/SQL/scheme.sql @@ -0,0 +1,1008 @@ +-- ============================================================================ +-- FERROVIA PANEL — SCHÉMA SQL COMPLET +-- Généré le 2026-08-25 par analyse exhaustive de l'application +-- ============================================================================ +-- Ce fichier contient TOUT le SQL nécessaire pour recréer la base de données. +-- Il est conçu pour être exécuté sur une instance Supabase (self-hosted ou cloud). +-- Ordre d'exécution : Extensions → Types → Fonctions → Tables → Séquences → +-- Contraintes → Index → Triggers → RLS → Grants +-- ============================================================================ + +-- ============================================================================ +-- 1. EXTENSIONS +-- ============================================================================ +CREATE EXTENSION IF NOT EXISTS "pgcrypto"; +CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; + +-- ============================================================================ +-- 2. TYPES ENUM +-- ============================================================================ +DO $$ BEGIN + CREATE TYPE "public"."service_type" AS ENUM ('TER','TGV','Intercités','Fret'); +EXCEPTION WHEN duplicate_object THEN NULL; +END $$; + +DO $$ BEGIN + CREATE TYPE "public"."station_type" AS ENUM ('interurbaine','ville'); +EXCEPTION WHEN duplicate_object THEN NULL; +END $$; + +DO $$ BEGIN + CREATE TYPE "public"."transport_type" AS ENUM ('bus','tram','metro','tram-train','train'); +EXCEPTION WHEN duplicate_object THEN NULL; +END $$; + +-- ============================================================================ +-- 3. FONCTIONS UTILITAIRES (triggers) +-- ============================================================================ +CREATE OR REPLACE FUNCTION "public"."handle_updated_at"() RETURNS trigger + LANGUAGE plpgsql AS $$ BEGIN NEW.updated_at = NOW(); RETURN NEW; END; $$; + +CREATE OR REPLACE FUNCTION "public"."set_updated_at"() RETURNS trigger + LANGUAGE plpgsql AS $$ BEGIN NEW.updated_at = now(); RETURN NEW; END; $$; + +CREATE OR REPLACE FUNCTION "public"."update_updated_at_column"() RETURNS trigger + LANGUAGE plpgsql AS $$ BEGIN NEW.updated_at = NOW(); RETURN NEW; END; $$; + +CREATE OR REPLACE FUNCTION "public"."update_travaux_updated_at"() RETURNS trigger + LANGUAGE plpgsql AS $$ BEGIN NEW.updated_at = now(); RETURN NEW; END; $$; + +CREATE OR REPLACE FUNCTION "public"."update_utilisateurs_updated_at"() RETURNS trigger + LANGUAGE plpgsql AS $$ BEGIN NEW.updated_at = NOW(); RETURN NEW; END; $$; + +-- ============================================================================ +-- 4. FONCTION D'AUDIT +-- ============================================================================ +CREATE OR REPLACE FUNCTION "public"."log_audit"() RETURNS trigger + LANGUAGE plpgsql SECURITY DEFINER AS $$ +DECLARE + v_changed_by_text text; + v_changed_by uuid; +BEGIN + v_changed_by_text := current_setting('jwt.claims.user_id', true); + IF v_changed_by_text IS NOT NULL AND v_changed_by_text <> '' THEN + BEGIN v_changed_by := v_changed_by_text::uuid; + EXCEPTION WHEN others THEN v_changed_by := NULL; END; + ELSE v_changed_by := NULL; + END IF; + IF (TG_OP = 'INSERT') THEN + INSERT INTO public.audit_logs(table_name, operation, record_id, changed_by, new_data, changed_at) + VALUES (TG_TABLE_NAME, 'INSERT', NEW.id, v_changed_by, row_to_json(NEW)::jsonb, now()); + RETURN NEW; + ELSIF (TG_OP = 'UPDATE') THEN + INSERT INTO public.audit_logs(table_name, operation, record_id, changed_by, old_data, new_data, changed_at) + VALUES (TG_TABLE_NAME, 'UPDATE', NEW.id, v_changed_by, row_to_json(OLD)::jsonb, row_to_json(NEW)::jsonb, now()); + RETURN NEW; + ELSIF (TG_OP = 'DELETE') THEN + INSERT INTO public.audit_logs(table_name, operation, record_id, changed_by, old_data, changed_at) + VALUES (TG_TABLE_NAME, 'DELETE', OLD.id, v_changed_by, row_to_json(OLD)::jsonb, now()); + RETURN OLD; + END IF; + RETURN NULL; +END; $$; + +-- ============================================================================ +-- 5. FONCTION STUB GTFS +-- ============================================================================ +CREATE OR REPLACE FUNCTION "public"."import_gtfs_rpc"(payload json) RETURNS json + LANGUAGE plpgsql AS $$ +BEGIN RETURN json_build_object('status', 'ok', 'created', 0, 'warnings', json_build_array()); +EXCEPTION WHEN OTHERS THEN RETURN json_build_object('status', 'error', 'message', SQLERRM); +END; $$; + +-- ============================================================================ +-- 6. TABLES (ordonnées par dépendances) +-- ============================================================================ + +-- ----------------------------------------------- +-- 6.01 audit_logs +-- ----------------------------------------------- +CREATE TABLE IF NOT EXISTS "public"."audit_logs" ( + "id" bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + "table_name" text NOT NULL, + "operation" text NOT NULL, + "record_id" uuid, + "changed_by" uuid, + "old_data" jsonb, + "new_data" jsonb, + "changed_at" timestamptz NOT NULL DEFAULT now() +); + +-- ----------------------------------------------- +-- 6.02 utilisateurs +-- ----------------------------------------------- +CREATE TABLE IF NOT EXISTS "public"."utilisateurs" ( + "id" uuid DEFAULT gen_random_uuid() NOT NULL PRIMARY KEY, + "email" varchar(255) NOT NULL UNIQUE, + "nom" varchar(100) NOT NULL, + "prenom" varchar(100) NOT NULL, + "role" varchar(50) NOT NULL DEFAULT 'utilisateur', + "actif" boolean NOT NULL DEFAULT true, + "password_hash" varchar(255), + "photo_url" text, + "region_admin" boolean DEFAULT false, + "region_id" varchar(100), + "region_nom" varchar(255), + "permissions" jsonb NOT NULL DEFAULT '{}'::jsonb, + "created_at" timestamptz DEFAULT now(), + "updated_at" timestamptz DEFAULT now() +); +CREATE UNIQUE INDEX IF NOT EXISTS "utilisateurs_email_lower_idx" ON "public"."utilisateurs" (lower(email::text)); +CREATE INDEX IF NOT EXISTS "idx_utilisateurs_email" ON "public"."utilisateurs" ("email"); +CREATE INDEX IF NOT EXISTS "idx_utilisateurs_role" ON "public"."utilisateurs" ("role"); +CREATE INDEX IF NOT EXISTS "idx_utilisateurs_actif" ON "public"."utilisateurs" ("actif"); +CREATE INDEX IF NOT EXISTS "idx_utilisateurs_region_id" ON "public"."utilisateurs" ("region_id"); + +-- ----------------------------------------------- +-- 6.03 gares +-- ----------------------------------------------- +CREATE TABLE IF NOT EXISTS "public"."gares" ( + "id" uuid DEFAULT gen_random_uuid() NOT NULL PRIMARY KEY, + "nom" text NOT NULL, + "type" varchar(50) NOT NULL DEFAULT 'ville', + "services" "public"."service_type"[] NOT NULL DEFAULT '{}'::service_type[], + "informations" jsonb DEFAULT '{}'::jsonb, + "quais_hall" jsonb DEFAULT '[]'::jsonb, + "created_at" timestamptz NOT NULL DEFAULT now(), + "updated_at" timestamptz NOT NULL DEFAULT now(), + "deleted_at" timestamptz +); + +-- ----------------------------------------------- +-- 6.04 gare_quais +-- ----------------------------------------------- +CREATE TABLE IF NOT EXISTS "public"."gare_quais" ( + "id" uuid DEFAULT gen_random_uuid() NOT NULL PRIMARY KEY, + "gare_id" uuid NOT NULL REFERENCES "public"."gares"("id") ON DELETE CASCADE, + "nom" text NOT NULL, + "distance_metres" integer NOT NULL DEFAULT 0, + "created_at" timestamptz NOT NULL DEFAULT now() +); +CREATE INDEX IF NOT EXISTS "idx_gare_quais_gare_id" ON "public"."gare_quais" ("gare_id"); + +-- ----------------------------------------------- +-- 6.05 gare_transports +-- ----------------------------------------------- +CREATE TABLE IF NOT EXISTS "public"."gare_transports" ( + "id" uuid DEFAULT gen_random_uuid() NOT NULL PRIMARY KEY, + "gare_id" uuid NOT NULL REFERENCES "public"."gares"("id") ON DELETE CASCADE, + "type" "public"."transport_type" NOT NULL, + "ligne" text, + "created_at" timestamptz NOT NULL DEFAULT now() +); +CREATE INDEX IF NOT EXISTS "idx_gare_transports_gare_id" ON "public"."gare_transports" ("gare_id"); + +-- ----------------------------------------------- +-- 6.06 types_train +-- ----------------------------------------------- +CREATE TABLE IF NOT EXISTS "public"."types_train" ( + "id" bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + "nom" varchar(255) NOT NULL, + "code" varchar(50), + "description" text, + "logo_url" text, + "logo_color_url" text, + "couleur" varchar(20) DEFAULT '#0074d9', + "created_at" timestamptz DEFAULT now(), + "updated_at" timestamptz DEFAULT now(), + "deleted_at" timestamptz +); +CREATE INDEX IF NOT EXISTS "idx_types_train_code" ON "public"."types_train" ("code"); +CREATE INDEX IF NOT EXISTS "idx_types_train_deleted_at" ON "public"."types_train" ("deleted_at"); + +-- ----------------------------------------------- +-- 6.07 materiel-roulant +-- ----------------------------------------------- +CREATE TABLE IF NOT EXISTS "public"."materiel-roulant" ( + "id" bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + "nom" text, + "nom_technique" text, + "capacite" integer, + "image_url" text, + "type_train" text, + "numero_serie" text UNIQUE, + "created_at" timestamptz DEFAULT now(), + "updated_at" timestamptz +); + +-- ----------------------------------------------- +-- 6.08 services_annuels +-- ----------------------------------------------- +CREATE TABLE IF NOT EXISTS "public"."services_annuels" ( + "id" uuid DEFAULT gen_random_uuid() NOT NULL PRIMARY KEY, + "nom" varchar(255) NOT NULL, + "code" varchar(50), + "date_debut" date NOT NULL, + "date_fin" date NOT NULL, + "description" text, + "actif" boolean DEFAULT true, + "created_at" timestamptz DEFAULT now(), + "updated_at" timestamptz DEFAULT now(), + "deleted_at" timestamptz +); +CREATE INDEX IF NOT EXISTS "idx_services_annuels_actif" ON "public"."services_annuels" ("actif") WHERE deleted_at IS NULL; +CREATE INDEX IF NOT EXISTS "idx_services_annuels_dates" ON "public"."services_annuels" ("date_debut","date_fin"); + +-- ----------------------------------------------- +-- 6.09 lignes +-- ----------------------------------------------- +CREATE TABLE IF NOT EXISTS "public"."lignes" ( + "id" uuid DEFAULT gen_random_uuid() NOT NULL PRIMARY KEY, + "nom" varchar(255) NOT NULL, + "code" varchar(50), + "gare_depart_id" uuid REFERENCES "public"."gares"("id") ON DELETE SET NULL, + "gare_arrivee_id" uuid REFERENCES "public"."gares"("id") ON DELETE SET NULL, + "actif" boolean DEFAULT true, + "fh_url" text, + "region" varchar(100), + "created_at" timestamptz DEFAULT now(), + "updated_at" timestamptz DEFAULT now(), + "deleted_at" timestamptz +); +CREATE INDEX IF NOT EXISTS "idx_lignes_actif" ON "public"."lignes" ("actif") WHERE deleted_at IS NULL; +CREATE INDEX IF NOT EXISTS "idx_lignes_fh_url" ON "public"."lignes" ("fh_url"); + +-- ----------------------------------------------- +-- 6.10 lignes_gares +-- ----------------------------------------------- +CREATE TABLE IF NOT EXISTS "public"."lignes_gares" ( + "id" uuid DEFAULT gen_random_uuid() NOT NULL PRIMARY KEY, + "ligne_id" uuid REFERENCES "public"."lignes"("id") ON DELETE CASCADE, + "gare_id" uuid REFERENCES "public"."gares"("id") ON DELETE CASCADE, + "ordre" integer NOT NULL, + "desserte" varchar(20) NOT NULL CHECK (desserte IN ('tous','partielle')), + "created_at" timestamptz DEFAULT now() +); +CREATE INDEX IF NOT EXISTS "idx_lignes_gares_ligne" ON "public"."lignes_gares" ("ligne_id"); +CREATE INDEX IF NOT EXISTS "idx_lignes_gares_ordre" ON "public"."lignes_gares" ("ligne_id","ordre"); + +-- ----------------------------------------------- +-- 6.11 horaires +-- ----------------------------------------------- +CREATE TABLE IF NOT EXISTS "public"."horaires" ( + "id" uuid DEFAULT gen_random_uuid() NOT NULL PRIMARY KEY, + "numero_train" varchar(50) NOT NULL, + "type_train" varchar(255), + "type_train_id" bigint REFERENCES "public"."types_train"("id") ON DELETE SET NULL, + "gare_depart_id" uuid REFERENCES "public"."gares"("id") ON DELETE SET NULL, + "gare_depart_nom" varchar(255), + "heure_depart" time, + "gare_arrivee_id" uuid REFERENCES "public"."gares"("id") ON DELETE SET NULL, + "gare_arrivee_nom" varchar(255), + "heure_arrivee" time, + "circule_lundi" boolean DEFAULT true, + "circule_mardi" boolean DEFAULT true, + "circule_mercredi" boolean DEFAULT true, + "circule_jeudi" boolean DEFAULT true, + "circule_vendredi" boolean DEFAULT true, + "circule_samedi" boolean DEFAULT true, + "circule_dimanche" boolean DEFAULT true, + "circule_jours_feries" boolean DEFAULT false, + "circule_dimanches_feries" boolean DEFAULT false, + "jours_personnalises" jsonb DEFAULT '[]'::jsonb, + "jours_non_circulation" jsonb DEFAULT '[]'::jsonb, + "materiel_roulant_id" bigint REFERENCES "public"."materiel-roulant"("id") ON DELETE SET NULL, + "ligne_id" uuid REFERENCES "public"."lignes"("id") ON DELETE SET NULL, + "ligne_nom" varchar(255), + "service_annuel_id" uuid REFERENCES "public"."services_annuels"("id") ON DELETE SET NULL, + "service_annuel_nom" varchar(255), + "est_substitution" boolean DEFAULT false, + "motif_substitution" varchar(255), + "gares_desservies" jsonb DEFAULT '[]'::jsonb, + "composition_train" jsonb DEFAULT '[]'::jsonb, + "substitution_disponible" boolean DEFAULT false, + "actif" boolean DEFAULT true, + "region_id" varchar(100), + "created_at" timestamptz DEFAULT now(), + "updated_at" timestamptz DEFAULT now(), + "deleted_at" timestamptz +); +CREATE INDEX IF NOT EXISTS "idx_horaires_numero_train" ON "public"."horaires" ("numero_train"); +CREATE INDEX IF NOT EXISTS "idx_horaires_gare_depart" ON "public"."horaires" ("gare_depart_id"); +CREATE INDEX IF NOT EXISTS "idx_horaires_gare_arrivee" ON "public"."horaires" ("gare_arrivee_id"); +CREATE INDEX IF NOT EXISTS "idx_horaires_actif" ON "public"."horaires" ("actif") WHERE deleted_at IS NULL; +CREATE INDEX IF NOT EXISTS "idx_horaires_type_train" ON "public"."horaires" ("type_train_id"); +CREATE INDEX IF NOT EXISTS "idx_horaires_materiel" ON "public"."horaires" ("materiel_roulant_id"); +CREATE INDEX IF NOT EXISTS "idx_horaires_ligne" ON "public"."horaires" ("ligne_id"); +CREATE INDEX IF NOT EXISTS "idx_horaires_service_annuel" ON "public"."horaires" ("service_annuel_id"); +CREATE INDEX IF NOT EXISTS "idx_horaires_substitution" ON "public"."horaires" ("est_substitution"); +CREATE INDEX IF NOT EXISTS "idx_horaires_region_id" ON "public"."horaires" ("region_id"); + +-- ----------------------------------------------- +-- 6.12 horaires_gares_desservies +-- ----------------------------------------------- +CREATE TABLE IF NOT EXISTS "public"."horaires_gares_desservies" ( + "id" bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + "horaire_id" uuid REFERENCES "public"."horaires"("id") ON DELETE CASCADE, + "gare_id" uuid REFERENCES "public"."gares"("id") ON DELETE SET NULL, + "gare_nom" varchar(255) NOT NULL, + "heure_arrivee" time, + "heure_depart" time, + "ordre" integer NOT NULL DEFAULT 0, + "quai" varchar(50), + "quai_id" uuid REFERENCES "public"."gare_quais"("id"), + "created_at" timestamptz DEFAULT now(), + "updated_at" timestamptz DEFAULT now() +); +CREATE INDEX IF NOT EXISTS "idx_gares_desservies_horaire" ON "public"."horaires_gares_desservies" ("horaire_id"); +CREATE INDEX IF NOT EXISTS "idx_gares_desservies_ordre" ON "public"."horaires_gares_desservies" ("horaire_id","ordre"); +CREATE INDEX IF NOT EXISTS "idx_hgd_gare_id" ON "public"."horaires_gares_desservies" ("gare_id"); + +-- ----------------------------------------------- +-- 6.13 horaires_substitutions (utilisé par l'API mais absent du dump) +-- ----------------------------------------------- +CREATE TABLE IF NOT EXISTS "public"."horaires_substitutions" ( + "id" uuid DEFAULT gen_random_uuid() NOT NULL PRIMARY KEY, + "horaire_id" uuid REFERENCES "public"."horaires"("id") ON DELETE SET NULL, + "region_id" varchar(100), + "region" varchar(255), + "numero_train" varchar(50), + "type_train" varchar(100), + "type_train_id" bigint, + "gare_depart_id" uuid REFERENCES "public"."gares"("id") ON DELETE SET NULL, + "gare_depart_nom" varchar(255), + "heure_depart" time, + "gare_arrivee_id" uuid REFERENCES "public"."gares"("id") ON DELETE SET NULL, + "gare_arrivee_nom" varchar(255), + "heure_arrivee" time, + "date_debut" timestamptz, + "date_fin" timestamptz, + "circule_lundi" boolean DEFAULT true, + "circule_mardi" boolean DEFAULT true, + "circule_mercredi" boolean DEFAULT true, + "circule_jeudi" boolean DEFAULT true, + "circule_vendredi" boolean DEFAULT true, + "circule_samedi" boolean DEFAULT true, + "circule_dimanche" boolean DEFAULT true, + "circule_jours_feries" boolean DEFAULT false, + "jours_personnalises" jsonb DEFAULT '[]'::jsonb, + "jours_non_circulation" jsonb DEFAULT '[]'::jsonb, + "materiel_roulant_id" bigint, + "ligne_id" uuid REFERENCES "public"."lignes"("id") ON DELETE SET NULL, + "ligne_nom" varchar(255), + "service_annuel_id" uuid REFERENCES "public"."services_annuels"("id") ON DELETE SET NULL, + "service_annuel_nom" varchar(255), + "est_substitution" boolean DEFAULT true, + "motif_substitution" varchar(255), + "gares_desservies" jsonb DEFAULT '[]'::jsonb, + "composition_train" jsonb DEFAULT '[]'::jsonb, + "actif" boolean DEFAULT true, + "created_at" timestamptz DEFAULT now(), + "updated_at" timestamptz DEFAULT now(), + "deleted_at" timestamptz +); +CREATE INDEX IF NOT EXISTS "idx_horaires_substitutions_region" ON "public"."horaires_substitutions" ("region_id"); +CREATE INDEX IF NOT EXISTS "idx_horaires_substitutions_horaire" ON "public"."horaires_substitutions" ("horaire_id"); +CREATE INDEX IF NOT EXISTS "idx_horaires_substitutions_actif" ON "public"."horaires_substitutions" ("actif") WHERE deleted_at IS NULL; + +-- ----------------------------------------------- +-- 6.14 perturbations +-- ----------------------------------------------- +CREATE TABLE IF NOT EXISTS "public"."perturbations" ( + "id" uuid DEFAULT gen_random_uuid() NOT NULL PRIMARY KEY, + "horaire_id" uuid NOT NULL REFERENCES "public"."horaires"("id") ON DELETE CASCADE, + "type_perturbation" varchar(50) NOT NULL CHECK (type_perturbation IN ('retard','suppression','modification_parcours')), + "duree_retard_minutes" integer, + "cause" varchar(100) NOT NULL CHECK (cause IN ('incident_technique','accident_personne','conditions_meteorologiques','travaux','mouvement_social','intervention_police','affluence','malveillance','autre')), + "cause_details" text, + "sillon_substitution_id" uuid REFERENCES "public"."horaires"("id") ON DELETE SET NULL, + "date_debut" timestamptz NOT NULL DEFAULT now(), + "date_fin" timestamptz, + "actif" boolean DEFAULT true, + "notes" text, + "region_id" varchar(100), + "created_by" uuid, + "perturbations_arrets" jsonb, + "jours_perturbation" jsonb, + "created_at" timestamptz DEFAULT now(), + "updated_at" timestamptz DEFAULT now(), + "deleted_at" timestamptz +); +CREATE INDEX IF NOT EXISTS "idx_perturbations_horaire" ON "public"."perturbations" ("horaire_id"); +CREATE INDEX IF NOT EXISTS "idx_perturbations_type" ON "public"."perturbations" ("type_perturbation"); +CREATE INDEX IF NOT EXISTS "idx_perturbations_actif" ON "public"."perturbations" ("actif") WHERE deleted_at IS NULL; +CREATE INDEX IF NOT EXISTS "idx_perturbations_dates" ON "public"."perturbations" ("date_debut","date_fin"); +CREATE INDEX IF NOT EXISTS "idx_perturbations_sillon_substitution" ON "public"."perturbations" ("sillon_substitution_id"); +CREATE INDEX IF NOT EXISTS "idx_perturbations_region_id" ON "public"."perturbations" ("region_id"); +CREATE INDEX IF NOT EXISTS "idx_perturbations_created_by" ON "public"."perturbations" ("created_by"); + +-- ----------------------------------------------- +-- 6.15 perturbations_modifications_parcours +-- ----------------------------------------------- +CREATE TABLE IF NOT EXISTS "public"."perturbations_modifications_parcours" ( + "id" uuid DEFAULT gen_random_uuid() NOT NULL PRIMARY KEY, + "perturbation_id" uuid NOT NULL REFERENCES "public"."perturbations"("id") ON DELETE CASCADE, + "type_modification" varchar(20) NOT NULL CHECK (type_modification IN ('gare_supprimee','gare_ajoutee','horaire_modifie')), + "gare_id" uuid REFERENCES "public"."gares"("id") ON DELETE SET NULL, + "gare_nom" varchar(255) NOT NULL, + "nouvelle_heure_arrivee" time, + "nouvelle_heure_depart" time, + "ordre" integer, + "created_at" timestamptz DEFAULT now() +); +CREATE INDEX IF NOT EXISTS "idx_modif_parcours_perturbation" ON "public"."perturbations_modifications_parcours" ("perturbation_id"); +CREATE INDEX IF NOT EXISTS "idx_modif_parcours_gare" ON "public"."perturbations_modifications_parcours" ("gare_id"); + +-- ----------------------------------------------- +-- 6.16 info_trafic +-- ----------------------------------------------- +CREATE TABLE IF NOT EXISTS "public"."info_trafic" ( + "id" uuid DEFAULT gen_random_uuid() NOT NULL PRIMARY KEY, + "titre" varchar(255) NOT NULL, + "description" text, + "type" varchar(50) NOT NULL CHECK (type IN ('perturbation','travaux','greve','meteo','autre')), + "severite" varchar(20) NOT NULL CHECK (severite IN ('info','warning','critical')), + "ligne" varchar(100), + "gare_id" uuid REFERENCES "public"."gares"("id") ON DELETE SET NULL, + "date_debut" timestamptz NOT NULL DEFAULT now(), + "date_fin" timestamptz, + "actif" boolean DEFAULT true, + "region_id" varchar(100), + "created_by" uuid, + "created_at" timestamptz DEFAULT now(), + "updated_at" timestamptz DEFAULT now(), + "deleted_at" timestamptz +); +CREATE INDEX IF NOT EXISTS "idx_info_trafic_actif" ON "public"."info_trafic" ("actif") WHERE deleted_at IS NULL; +CREATE INDEX IF NOT EXISTS "idx_info_trafic_dates" ON "public"."info_trafic" ("date_debut","date_fin"); +CREATE INDEX IF NOT EXISTS "idx_info_trafic_region_id" ON "public"."info_trafic" ("region_id"); +CREATE INDEX IF NOT EXISTS "idx_info_trafic_created_by" ON "public"."info_trafic" ("created_by"); + +-- ----------------------------------------------- +-- 6.17 evenements +-- ----------------------------------------------- +CREATE TABLE IF NOT EXISTS "public"."evenements" ( + "id" uuid DEFAULT gen_random_uuid() NOT NULL PRIMARY KEY, + "titre" varchar(255) NOT NULL, + "description" text, + "lieu" varchar(255), + "gare_id" uuid REFERENCES "public"."gares"("id") ON DELETE SET NULL, + "date_debut" timestamptz NOT NULL, + "date_fin" timestamptz, + "image_url" varchar(500), + "lien_externe" varchar(500), + "actif" boolean DEFAULT true, + "visible_site" boolean DEFAULT true, + "region_id" varchar(100), + "created_by" uuid, + "created_at" timestamptz DEFAULT now(), + "updated_at" timestamptz DEFAULT now(), + "deleted_at" timestamptz +); +CREATE INDEX IF NOT EXISTS "idx_evenements_actif" ON "public"."evenements" ("actif") WHERE deleted_at IS NULL; +CREATE INDEX IF NOT EXISTS "idx_evenements_dates" ON "public"."evenements" ("date_debut","date_fin"); +CREATE INDEX IF NOT EXISTS "idx_evenements_region_id" ON "public"."evenements" ("region_id"); +CREATE INDEX IF NOT EXISTS "idx_evenements_created_by" ON "public"."evenements" ("created_by"); + +-- ----------------------------------------------- +-- 6.18 actualites +-- ----------------------------------------------- +CREATE TABLE IF NOT EXISTS "public"."actualites" ( + "id" uuid DEFAULT gen_random_uuid() NOT NULL PRIMARY KEY, + "titre" varchar(255) NOT NULL, + "contenu" text NOT NULL, + "resume" varchar(500), + "categorie" varchar(50) CHECK (categorie IN ('general','service','promotion','partenariat','autre')), + "image_url" varchar(500), + "image_couverture" text, + "date_publication" timestamptz NOT NULL DEFAULT now(), + "auteur" varchar(100), + "actif" boolean DEFAULT true, + "mise_en_avant" boolean DEFAULT false, + "region_id" varchar(100), + "created_by" uuid, + "created_at" timestamptz DEFAULT now(), + "updated_at" timestamptz DEFAULT now(), + "deleted_at" timestamptz +); +CREATE INDEX IF NOT EXISTS "idx_actualites_actif" ON "public"."actualites" ("actif") WHERE deleted_at IS NULL; +CREATE INDEX IF NOT EXISTS "idx_actualites_publication" ON "public"."actualites" ("date_publication"); +CREATE INDEX IF NOT EXISTS "idx_actualites_region_id" ON "public"."actualites" ("region_id"); +CREATE INDEX IF NOT EXISTS "idx_actualites_created_by" ON "public"."actualites" ("created_by"); + +-- ----------------------------------------------- +-- 6.19 travaux +-- ----------------------------------------------- +CREATE TABLE IF NOT EXISTS "public"."travaux" ( + "id" uuid DEFAULT gen_random_uuid() NOT NULL PRIMARY KEY, + "titre" text NOT NULL, + "description" text, + "date_debut" date NOT NULL, + "date_fin" date NOT NULL, + "jours_travaux" text[] NOT NULL, + "heure_debut" time NOT NULL, + "heure_fin" time NOT NULL, + "actif" boolean DEFAULT true, + "ligne_id" uuid REFERENCES "public"."lignes"("id") ON DELETE SET NULL, + "ligne_nom" text, + "region_id" varchar(100), + "created_by" uuid REFERENCES "public"."utilisateurs"("id"), + "substitutions_attribuees" jsonb DEFAULT '[]'::jsonb, + "created_at" timestamptz DEFAULT now(), + "updated_at" timestamptz DEFAULT now(), + "deleted_at" timestamptz, + CONSTRAINT "valid_dates" CHECK (date_fin >= date_debut), + CONSTRAINT "valid_hours" CHECK (heure_fin > heure_debut) +); +CREATE INDEX IF NOT EXISTS "idx_travaux_dates" ON "public"."travaux" ("date_debut","date_fin"); +CREATE INDEX IF NOT EXISTS "idx_travaux_actif" ON "public"."travaux" ("actif"); +CREATE INDEX IF NOT EXISTS "idx_travaux_deleted" ON "public"."travaux" ("deleted_at"); +CREATE INDEX IF NOT EXISTS "idx_travaux_ligne_id" ON "public"."travaux" ("ligne_id"); +CREATE INDEX IF NOT EXISTS "idx_travaux_region_id" ON "public"."travaux" ("region_id"); +CREATE INDEX IF NOT EXISTS "idx_travaux_created_by" ON "public"."travaux" ("created_by"); +CREATE INDEX IF NOT EXISTS "idx_travaux_substitutions_attribuees_gin" ON "public"."travaux" USING gin ("substitutions_attribuees"); + +-- ----------------------------------------------- +-- 6.20 travaux_substitutions +-- ----------------------------------------------- +CREATE TABLE IF NOT EXISTS "public"."travaux_substitutions" ( + "id" uuid DEFAULT gen_random_uuid() NOT NULL PRIMARY KEY, + "travaux_id" uuid NOT NULL REFERENCES "public"."travaux"("id") ON DELETE CASCADE, + "horaire_id" uuid NOT NULL REFERENCES "public"."horaires"("id") ON DELETE CASCADE, + "ordre" integer DEFAULT 0, + "created_at" timestamptz DEFAULT now(), + UNIQUE ("travaux_id","horaire_id") +); +CREATE INDEX IF NOT EXISTS "idx_travaux_substitutions_travaux" ON "public"."travaux_substitutions" ("travaux_id"); +CREATE INDEX IF NOT EXISTS "idx_travaux_substitutions_horaire" ON "public"."travaux_substitutions" ("horaire_id"); + +-- ----------------------------------------------- +-- 6.21 tarifs +-- ----------------------------------------------- +CREATE TABLE IF NOT EXISTS "public"."tarifs" ( + "id" integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + "nom" varchar(255) NOT NULL, + "type" varchar(50) NOT NULL, + "description" text, + "prix_minimum" numeric(10,2), + "prix_par_km" numeric(10,3), + "age_maximum" integer, + "regions" text[] NOT NULL, + "date_debut" date, + "date_fin" date, + "ordre" integer NOT NULL DEFAULT 0, + "page_dediee" boolean DEFAULT false, + "infos" jsonb DEFAULT '{}'::jsonb, + "details" jsonb, + "created_at" timestamp DEFAULT now(), + "updated_at" timestamp DEFAULT now() +); +CREATE INDEX IF NOT EXISTS "idx_tarifs_ordre" ON "public"."tarifs" ("ordre"); +CREATE INDEX IF NOT EXISTS "idx_tarifs_regions" ON "public"."tarifs" USING gin ("regions"); + +-- ----------------------------------------------- +-- 6.22 parametres +-- ----------------------------------------------- +CREATE TABLE IF NOT EXISTS "public"."parametres" ( + "id" uuid DEFAULT gen_random_uuid() NOT NULL PRIMARY KEY, + "cle" varchar(100) NOT NULL UNIQUE, + "valeur" jsonb, + "created_at" timestamptz DEFAULT now(), + "updated_at" timestamptz DEFAULT now() +); + +-- ----------------------------------------------- +-- 6.23 menus_regionaux +-- ----------------------------------------------- +CREATE TABLE IF NOT EXISTS "public"."menus_regionaux" ( + "id" uuid DEFAULT gen_random_uuid() NOT NULL PRIMARY KEY, + "nom" varchar(200) NOT NULL, + "description" text, + "actif" boolean DEFAULT false, + "structure" jsonb DEFAULT '[]'::jsonb, + "region_id" varchar(64), + "created_at" timestamptz DEFAULT now(), + "updated_at" timestamptz DEFAULT now() +); +CREATE INDEX IF NOT EXISTS "idx_menus_regionaux_region_id" ON "public"."menus_regionaux" ("region_id"); + +-- ----------------------------------------------- +-- 6.24 pages (CMS) +-- ----------------------------------------------- +CREATE TABLE IF NOT EXISTS "public"."pages" ( + "id" uuid DEFAULT gen_random_uuid() NOT NULL PRIMARY KEY, + "titre" text NOT NULL, + "slug" text, + "contenu" jsonb DEFAULT '[]'::jsonb, + "statut" text DEFAULT 'brouillon', + "region_id" varchar(255), + "created_at" timestamptz DEFAULT now(), + "updated_at" timestamptz DEFAULT now(), + "deleted_at" timestamptz +); + +-- ----------------------------------------------- +-- 6.25 page_personnalisation +-- ----------------------------------------------- +CREATE TABLE IF NOT EXISTS "public"."page_personnalisation" ( + "id" uuid DEFAULT gen_random_uuid() NOT NULL PRIMARY KEY, + "page" text NOT NULL UNIQUE, + "config" jsonb DEFAULT '{}'::jsonb, + "created_at" timestamptz DEFAULT now(), + "updated_at" timestamptz DEFAULT now() +); + +-- ----------------------------------------------- +-- 6.26 info_gares +-- ----------------------------------------------- +CREATE TABLE IF NOT EXISTS "public"."info_gares" ( + "id" uuid DEFAULT gen_random_uuid() NOT NULL PRIMARY KEY, + "titre" text, + "description" text, + "debut_affichage" timestamptz, + "fin_affichage" timestamptz, + "gare_id" uuid REFERENCES "public"."gares"("id") ON DELETE SET NULL, + "ligne_id" uuid REFERENCES "public"."lignes"("id") ON DELETE SET NULL, + "gare_ids" uuid[] DEFAULT '{}'::uuid[], + "ligne_ids" uuid[] DEFAULT '{}'::uuid[], + "type" text DEFAULT 'Information', + "created_at" timestamptz DEFAULT now(), + "deleted_at" timestamptz +); + +-- ----------------------------------------------- +-- 6.27 regles_quais +-- ----------------------------------------------- +CREATE TABLE IF NOT EXISTS "public"."regles_quais" ( + "id" uuid DEFAULT gen_random_uuid() NOT NULL PRIMARY KEY, + "gare_id" uuid NOT NULL REFERENCES "public"."gares"("id") ON DELETE CASCADE, + "ligne_id" uuid REFERENCES "public"."lignes"("id") ON DELETE SET NULL, + "type_regle" text, + "quais_concernes" jsonb DEFAULT '[]'::jsonb, + "created_at" timestamptz DEFAULT now() +); +CREATE INDEX IF NOT EXISTS "idx_regles_quais_gare_id" ON "public"."regles_quais" ("gare_id"); + +-- ----------------------------------------------- +-- 6.28 messagerie_threads +-- ----------------------------------------------- +CREATE TABLE IF NOT EXISTS "public"."messagerie_threads" ( + "id" uuid DEFAULT gen_random_uuid() NOT NULL PRIMARY KEY, + "created_at" timestamptz NOT NULL DEFAULT now(), + "updated_at" timestamptz NOT NULL DEFAULT now() +); + +-- ----------------------------------------------- +-- 6.29 messagerie_participants +-- ----------------------------------------------- +CREATE TABLE IF NOT EXISTS "public"."messagerie_participants" ( + "id" uuid DEFAULT gen_random_uuid() NOT NULL PRIMARY KEY, + "thread_id" uuid NOT NULL REFERENCES "public"."messagerie_threads"("id") ON DELETE CASCADE, + "user_id" uuid NOT NULL REFERENCES "public"."utilisateurs"("id") ON DELETE CASCADE, + "created_at" timestamptz NOT NULL DEFAULT now(), + UNIQUE ("thread_id","user_id") +); +CREATE INDEX IF NOT EXISTS "idx_messagerie_participants_user_id" ON "public"."messagerie_participants" ("user_id"); + +-- ----------------------------------------------- +-- 6.30 messagerie_messages +-- ----------------------------------------------- +CREATE TABLE IF NOT EXISTS "public"."messagerie_messages" ( + "id" uuid DEFAULT gen_random_uuid() NOT NULL PRIMARY KEY, + "thread_id" uuid NOT NULL REFERENCES "public"."messagerie_threads"("id") ON DELETE CASCADE, + "sender_id" uuid NOT NULL REFERENCES "public"."utilisateurs"("id") ON DELETE CASCADE, + "contenu" text NOT NULL, + "created_at" timestamptz NOT NULL DEFAULT now() +); +CREATE INDEX IF NOT EXISTS "idx_messagerie_messages_thread_id" ON "public"."messagerie_messages" ("thread_id"); +CREATE INDEX IF NOT EXISTS "idx_messagerie_messages_created_at" ON "public"."messagerie_messages" ("created_at" DESC); + +-- ----------------------------------------------- +-- 6.31 profils_utilisateurs (app mobile / client) +-- ----------------------------------------------- +CREATE TABLE IF NOT EXISTS "public"."profils_utilisateurs" ( + "id" uuid NOT NULL PRIMARY KEY, + "email" text NOT NULL, + "prenom" text, + "nom" text, + "date_naissance" date, + "telephone" text, + "carte_reduction" text, + "gares_favorites" text[] DEFAULT '{}'::text[], + "lignes_favorites" text[] DEFAULT '{}'::text[], + "cartes_bancaires" jsonb DEFAULT '[]'::jsonb, + "mode_paiement_favori" text, + "commandes" jsonb DEFAULT '[]'::jsonb, + "panier" jsonb DEFAULT '[]'::jsonb, + "trajets_favoris" text[] DEFAULT '{}'::text[], + "created_at" timestamptz DEFAULT now(), + "updated_at" timestamptz DEFAULT now() +); +CREATE INDEX IF NOT EXISTS "idx_profils_email" ON "public"."profils_utilisateurs" ("email"); + +-- ----------------------------------------------- +-- 6.32 banque_mots (annonces sonores) +-- ----------------------------------------------- +CREATE TABLE IF NOT EXISTS "public"."banque_mots" ( + "id" uuid DEFAULT gen_random_uuid() NOT NULL PRIMARY KEY, + "label" text, + "nom_fichier" text, + "storage_path" text, + "categorie" text DEFAULT 'general', + "format" text DEFAULT 'mp3', + "taille_bytes" bigint, + "description" text, + "duree_ms" integer, + "actif" boolean NOT NULL DEFAULT true, + "created_by" uuid, + "created_at" timestamptz NOT NULL DEFAULT now(), + "updated_at" timestamptz DEFAULT now(), + "deleted_at" timestamptz +); +CREATE INDEX IF NOT EXISTS "idx_banque_mots_categorie" ON "public"."banque_mots" ("categorie"); +CREATE INDEX IF NOT EXISTS "idx_banque_mots_label" ON "public"."banque_mots" ("label"); +CREATE INDEX IF NOT EXISTS "idx_banque_mots_actif" ON "public"."banque_mots" ("actif") WHERE deleted_at IS NULL; + +-- ----------------------------------------------- +-- 6.33 annonces_templates +-- ----------------------------------------------- +CREATE TABLE IF NOT EXISTS "public"."annonces_templates" ( + "id" uuid DEFAULT gen_random_uuid() NOT NULL PRIMARY KEY, + "nom" text NOT NULL, + "type_annonce" text NOT NULL, + "description" text, + "sequence" jsonb NOT NULL DEFAULT '[]'::jsonb, + "variables" jsonb NOT NULL DEFAULT '[]'::jsonb, + "tts_texte" text, + "actif" boolean NOT NULL DEFAULT true, + "created_by" uuid, + "created_at" timestamptz NOT NULL DEFAULT now(), + "updated_at" timestamptz DEFAULT now(), + "deleted_at" timestamptz +); +CREATE INDEX IF NOT EXISTS "idx_templates_type" ON "public"."annonces_templates" ("type_annonce"); +CREATE INDEX IF NOT EXISTS "idx_templates_actif" ON "public"."annonces_templates" ("actif") WHERE deleted_at IS NULL; + +-- ----------------------------------------------- +-- 6.34 annonces_enregistrees +-- ----------------------------------------------- +CREATE TABLE IF NOT EXISTS "public"."annonces_enregistrees" ( + "id" uuid DEFAULT gen_random_uuid() NOT NULL PRIMARY KEY, + "nom" text, + "type_annonce" text, + "template_id" uuid REFERENCES "public"."annonces_templates"("id"), + "storage_path" text, + "nom_fichier" text, + "variables_values" jsonb DEFAULT '{}'::jsonb, + "mode_generation" text DEFAULT 'tts', + "duree_ms" integer, + "taille_bytes" bigint, + "gare_id" uuid REFERENCES "public"."gares"("id"), + "diffuse" boolean DEFAULT false, + "date_diffusion" timestamptz, + "notes" text, + "created_by" uuid, + "created_at" timestamptz NOT NULL DEFAULT now(), + "updated_at" timestamptz DEFAULT now(), + "deleted_at" timestamptz +); + +-- ----------------------------------------------- +-- 6.35 annonces_diffusions +-- ----------------------------------------------- +CREATE TABLE IF NOT EXISTS "public"."annonces_diffusions" ( + "id" uuid DEFAULT gen_random_uuid() NOT NULL PRIMARY KEY, + "annonce_id" uuid REFERENCES "public"."annonces_enregistrees"("id") ON DELETE SET NULL, + "gare_id" uuid REFERENCES "public"."gares"("id") ON DELETE SET NULL, + "audio_path" text, + "tts_texte" text, + "created_at" timestamptz NOT NULL DEFAULT now() +); + +-- ============================================================================ +-- 7. TRIGGERS +-- ============================================================================ +DROP TRIGGER IF EXISTS "tr_utilisateurs_set_updated_at" ON "public"."utilisateurs"; +CREATE TRIGGER "tr_utilisateurs_set_updated_at" BEFORE UPDATE ON "public"."utilisateurs" + FOR EACH ROW EXECUTE FUNCTION "public"."set_updated_at"(); + +DROP TRIGGER IF EXISTS "tr_utilisateurs_audit" ON "public"."utilisateurs"; +CREATE TRIGGER "tr_utilisateurs_audit" AFTER INSERT OR DELETE OR UPDATE ON "public"."utilisateurs" + FOR EACH ROW EXECUTE FUNCTION "public"."log_audit"(); + +DROP TRIGGER IF EXISTS "tr_gares_set_updated_at" ON "public"."gares"; +CREATE TRIGGER "tr_gares_set_updated_at" BEFORE UPDATE ON "public"."gares" + FOR EACH ROW EXECUTE FUNCTION "public"."set_updated_at"(); + +DROP TRIGGER IF EXISTS "tr_gares_audit" ON "public"."gares"; +CREATE TRIGGER "tr_gares_audit" AFTER INSERT OR DELETE OR UPDATE ON "public"."gares" + FOR EACH ROW EXECUTE FUNCTION "public"."log_audit"(); + +DROP TRIGGER IF EXISTS "update_horaires_updated_at" ON "public"."horaires"; +CREATE TRIGGER "update_horaires_updated_at" BEFORE UPDATE ON "public"."horaires" + FOR EACH ROW EXECUTE FUNCTION "public"."update_updated_at_column"(); + +DROP TRIGGER IF EXISTS "update_horaires_gares_desservies_updated_at" ON "public"."horaires_gares_desservies"; +CREATE TRIGGER "update_horaires_gares_desservies_updated_at" BEFORE UPDATE ON "public"."horaires_gares_desservies" + FOR EACH ROW EXECUTE FUNCTION "public"."update_updated_at_column"(); + +DROP TRIGGER IF EXISTS "update_perturbations_updated_at" ON "public"."perturbations"; +CREATE TRIGGER "update_perturbations_updated_at" BEFORE UPDATE ON "public"."perturbations" + FOR EACH ROW EXECUTE FUNCTION "public"."update_updated_at_column"(); + +DROP TRIGGER IF EXISTS "update_info_trafic_updated_at" ON "public"."info_trafic"; +CREATE TRIGGER "update_info_trafic_updated_at" BEFORE UPDATE ON "public"."info_trafic" + FOR EACH ROW EXECUTE FUNCTION "public"."update_updated_at_column"(); + +DROP TRIGGER IF EXISTS "update_evenements_updated_at" ON "public"."evenements"; +CREATE TRIGGER "update_evenements_updated_at" BEFORE UPDATE ON "public"."evenements" + FOR EACH ROW EXECUTE FUNCTION "public"."update_updated_at_column"(); + +DROP TRIGGER IF EXISTS "update_actualites_updated_at" ON "public"."actualites"; +CREATE TRIGGER "update_actualites_updated_at" BEFORE UPDATE ON "public"."actualites" + FOR EACH ROW EXECUTE FUNCTION "public"."update_updated_at_column"(); + +DROP TRIGGER IF EXISTS "update_lignes_updated_at" ON "public"."lignes"; +CREATE TRIGGER "update_lignes_updated_at" BEFORE UPDATE ON "public"."lignes" + FOR EACH ROW EXECUTE FUNCTION "public"."update_updated_at_column"(); + +DROP TRIGGER IF EXISTS "update_services_annuels_updated_at" ON "public"."services_annuels"; +CREATE TRIGGER "update_services_annuels_updated_at" BEFORE UPDATE ON "public"."services_annuels" + FOR EACH ROW EXECUTE FUNCTION "public"."update_updated_at_column"(); + +DROP TRIGGER IF EXISTS "trigger_update_travaux_updated_at" ON "public"."travaux"; +CREATE TRIGGER "trigger_update_travaux_updated_at" BEFORE UPDATE ON "public"."travaux" + FOR EACH ROW EXECUTE FUNCTION "public"."update_travaux_updated_at"(); + +DROP TRIGGER IF EXISTS "set_profils_updated_at" ON "public"."profils_utilisateurs"; +CREATE TRIGGER "set_profils_updated_at" BEFORE UPDATE ON "public"."profils_utilisateurs" + FOR EACH ROW EXECUTE FUNCTION "public"."handle_updated_at"(); + +-- ============================================================================ +-- 8. FONCTIONS RPC MÉTIER +-- ============================================================================ + +-- Authentification +CREATE OR REPLACE FUNCTION "public"."authenticate_user"(p_email text, p_password text) +RETURNS TABLE(id uuid, email text, nom text, prenom text, name text, role text, region_admin boolean, region_id text) +LANGUAGE plpgsql STABLE SECURITY DEFINER AS $$ +DECLARE + has_deleted_at boolean; + sql text; +BEGIN + SELECT EXISTS(SELECT 1 FROM information_schema.columns + WHERE table_schema='public' AND table_name='utilisateurs' AND column_name='deleted_at') + INTO has_deleted_at; + sql := 'SELECT u.id::uuid, u.email::text, u.nom::text, u.prenom::text, ' + || '(COALESCE(u.nom,'''') || '' '' || COALESCE(u.prenom,''''))::text as name, ' + || 'u.role::text, ' + || 'COALESCE(u.region_admin, false)::boolean as region_admin, ' + || 'u.region_id::text ' + || 'FROM public.utilisateurs u ' + || 'WHERE lower(u.email) = lower($1) ' + || 'AND u.password_hash IS NOT NULL ' + || 'AND u.password_hash = crypt($2, u.password_hash) ' + || 'AND coalesce(u.actif, true) = true'; + IF has_deleted_at THEN sql := sql || ' AND u.deleted_at IS NULL'; END IF; + RETURN QUERY EXECUTE sql USING p_email, p_password; +END; $$; + +-- Création utilisateur avec mot de passe +CREATE OR REPLACE FUNCTION "public"."create_user_with_password"( + p_email text, p_nom text, p_prenom text, + p_role text DEFAULT 'utilisateur', p_actif boolean DEFAULT true, + p_password text DEFAULT NULL, p_photo_url text DEFAULT NULL, + p_region_admin boolean DEFAULT false, p_region_id text DEFAULT NULL +) RETURNS TABLE(id uuid, email text, nom text, prenom text, role text, actif boolean, photo_url text, region_admin boolean, region_id text) +LANGUAGE plpgsql SECURITY DEFINER AS $$ +DECLARE new_id uuid; +BEGIN + INSERT INTO public.utilisateurs (email, nom, prenom, role, actif, password_hash, photo_url, region_admin, region_id, created_at) + VALUES (lower(p_email), p_nom, p_prenom, p_role, p_actif, + CASE WHEN p_password IS NOT NULL AND p_password != '' THEN crypt(p_password, gen_salt('bf')) ELSE NULL END, + p_photo_url, p_region_admin, p_region_id, NOW()) + RETURNING utilisateurs.id INTO new_id; + RETURN QUERY SELECT u.id, u.email::text, u.nom::text, u.prenom::text, u.role::text, + u.actif, u.photo_url::text, u.region_admin, u.region_id::text + FROM public.utilisateurs u WHERE u.id = new_id; +END; $$; + +-- Mise à jour mot de passe +CREATE OR REPLACE FUNCTION "public"."update_user_password"(p_user_id uuid, p_password text) RETURNS void +LANGUAGE plpgsql SECURITY DEFINER AS $$ +BEGIN + IF p_password IS NULL OR p_password = '' THEN RAISE EXCEPTION 'Le mot de passe ne peut pas être vide'; END IF; + UPDATE public.utilisateurs SET password_hash = crypt(p_password, gen_salt('bf')), updated_at = NOW() WHERE id = p_user_id; + IF NOT FOUND THEN RAISE EXCEPTION 'Utilisateur non trouvé'; END IF; +END; $$; + +-- Détails d'une gare +CREATE OR REPLACE FUNCTION "public"."get_gare_details"(p_gare_id uuid) +RETURNS TABLE(id uuid, nom text, type varchar(50), services "public"."service_type"[], quais jsonb, transports jsonb, created_at timestamptz, updated_at timestamptz) +LANGUAGE plpgsql STABLE SECURITY DEFINER AS $$ +BEGIN + RETURN QUERY SELECT g.id, g.nom, g.type, g.services, + COALESCE((SELECT jsonb_agg(jsonb_build_object('id',q.id,'nom',q.nom,'distance_metres',q.distance_metres)) + FROM public.gare_quais q WHERE q.gare_id = g.id), '[]'::jsonb) as quais, + COALESCE((SELECT jsonb_agg(jsonb_build_object('id',t.id,'type',t.type,'ligne',t.ligne)) + FROM public.gare_transports t WHERE t.gare_id = g.id), '[]'::jsonb) as transports, + g.created_at, g.updated_at + FROM public.gares g WHERE g.id = p_gare_id AND g.deleted_at IS NULL; +END; $$; + +-- ============================================================================ +-- 9. ROW LEVEL SECURITY (RLS) + POLICIES +-- ============================================================================ + +-- menus_regionaux +ALTER TABLE "public"."menus_regionaux" ENABLE ROW LEVEL SECURITY; +CREATE POLICY "menus_select" ON "public"."menus_regionaux" FOR SELECT USING (true); +CREATE POLICY "menus_insert" ON "public"."menus_regionaux" FOR INSERT WITH CHECK (true); +CREATE POLICY "menus_update" ON "public"."menus_regionaux" FOR UPDATE USING (true); +CREATE POLICY "menus_delete" ON "public"."menus_regionaux" FOR DELETE USING (true); + +-- parametres +ALTER TABLE "public"."parametres" ENABLE ROW LEVEL SECURITY; +CREATE POLICY "parametres_select_all" ON "public"."parametres" FOR SELECT USING (true); +CREATE POLICY "parametres_insert_all" ON "public"."parametres" FOR INSERT WITH CHECK (true); +CREATE POLICY "parametres_update_all" ON "public"."parametres" FOR UPDATE USING (true); +CREATE POLICY "parametres_delete_all" ON "public"."parametres" FOR DELETE USING (true); + +-- messagerie_threads +ALTER TABLE "public"."messagerie_threads" ENABLE ROW LEVEL SECURITY; +CREATE POLICY "messagerie_threads_insert" ON "public"."messagerie_threads" FOR INSERT TO "anon" WITH CHECK (true); +CREATE POLICY "messagerie_threads_select" ON "public"."messagerie_threads" FOR SELECT TO "anon" + USING (EXISTS (SELECT 1 FROM "public"."messagerie_participants" p WHERE p.thread_id = messagerie_threads.id)); +CREATE POLICY "messagerie_threads_update" ON "public"."messagerie_threads" FOR UPDATE TO "anon" + USING (EXISTS (SELECT 1 FROM "public"."messagerie_participants" p WHERE p.thread_id = messagerie_threads.id)) WITH CHECK (true); + +-- messagerie_participants +ALTER TABLE "public"."messagerie_participants" ENABLE ROW LEVEL SECURITY; +CREATE POLICY "messagerie_participants_insert" ON "public"."messagerie_participants" FOR INSERT TO "anon" WITH CHECK (true); +CREATE POLICY "messagerie_participants_select" ON "public"."messagerie_participants" FOR SELECT TO "anon" USING (true); + +-- messagerie_messages +ALTER TABLE "public"."messagerie_messages" ENABLE ROW LEVEL SECURITY; +CREATE POLICY "messagerie_messages_insert" ON "public"."messagerie_messages" FOR INSERT TO "anon" + WITH CHECK (EXISTS (SELECT 1 FROM "public"."messagerie_participants" p WHERE p.thread_id = messagerie_messages.thread_id AND p.user_id = messagerie_messages.sender_id)); +CREATE POLICY "messagerie_messages_select" ON "public"."messagerie_messages" FOR SELECT TO "anon" + USING (EXISTS (SELECT 1 FROM "public"."messagerie_participants" p WHERE p.thread_id = messagerie_messages.thread_id)); + +-- profils_utilisateurs +ALTER TABLE "public"."profils_utilisateurs" ENABLE ROW LEVEL SECURITY; +CREATE POLICY "Lecture profil propre" ON "public"."profils_utilisateurs" FOR SELECT USING (auth.uid() = id); +CREATE POLICY "Insertion profil propre" ON "public"."profils_utilisateurs" FOR INSERT WITH CHECK (auth.uid() = id); +CREATE POLICY "Mise à jour profil propre" ON "public"."profils_utilisateurs" FOR UPDATE USING (auth.uid() = id) WITH CHECK (auth.uid() = id); +CREATE POLICY "Suppression profil propre" ON "public"."profils_utilisateurs" FOR DELETE USING (auth.uid() = id); + +-- ============================================================================ +-- 10. GRANTS — Accès complet pour anon / authenticated / service_role +-- ============================================================================ +GRANT USAGE ON SCHEMA "public" TO "postgres", "anon", "authenticated", "service_role"; + +-- Tables +GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA "public" TO "anon", "authenticated", "service_role"; + +-- Séquences +GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA "public" TO "anon", "authenticated", "service_role"; + +-- Fonctions +GRANT EXECUTE ON ALL FUNCTIONS IN SCHEMA "public" TO "anon", "authenticated", "service_role"; + +-- Droits par défaut pour les futurs objets +ALTER DEFAULT PRIVILEGES IN SCHEMA "public" GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO "anon", "authenticated", "service_role"; +ALTER DEFAULT PRIVILEGES IN SCHEMA "public" GRANT USAGE, SELECT ON SEQUENCES TO "anon", "authenticated", "service_role"; +ALTER DEFAULT PRIVILEGES IN SCHEMA "public" GRANT EXECUTE ON FUNCTIONS TO "anon", "authenticated", "service_role"; + +-- ============================================================================ +-- 11. DONNÉES INITIALES — Utilisateur administrateur +-- ============================================================================ +INSERT INTO "public"."utilisateurs" (email, nom, prenom, password_hash, role, actif, region_admin) +VALUES ( + 'admin@ferrovia.fr', + 'Admin', + 'Ferrovia', + crypt('password', gen_salt('bf')), + 'admin', + true, + true +) ON CONFLICT (email) DO UPDATE SET + password_hash = EXCLUDED.password_hash, + role = EXCLUDED.role, + actif = EXCLUDED.actif, + region_admin = EXCLUDED.region_admin, + updated_at = NOW(); + +-- ============================================================================ +-- FIN DU SCHÉMA — 35 tables, 3 enums, 12 fonctions, 14 triggers, 1 admin +-- ============================================================================ diff --git a/deploy.sh b/deploy.sh new file mode 100644 index 0000000..b5c655f --- /dev/null +++ b/deploy.sh @@ -0,0 +1,29 @@ +#!/bin/bash + +echo "🚀 Préparation du déploiement en production..." + +# 1. Arrêter les anciennes instances PM2 si elles existent +echo "🛑 Arrêt des anciennes instances..." +pm2 delete ferrovia-panel 2>/dev/null +pm2 delete ferrovia-updater 2>/dev/null + +# 2. Installation des dépendances +echo "📦 Installation des dépendances..." +npm install +npm install express cors + +# 3. Compilation de l'application React +echo "🏗️ Compilation de l'application React..." +npm run build + +# 4. Démarrage de l'écosystème PM2 +echo "🌐 Démarrage des serveurs (App + Updater)..." +pm2 start ecosystem.config.js + +# 5. Sauvegarde de la configuration PM2 pour le démarrage du serveur +echo "💾 Sauvegarde de la configuration PM2..." +pm2 save + +echo "✅ Déploiement terminé avec succès !" +echo "🌐 L'application est disponible sur le port 4000" +echo "🔄 L'updater écoute sur le port 3001" diff --git a/ecosystem.config.js b/ecosystem.config.js new file mode 100644 index 0000000..f0d03b6 --- /dev/null +++ b/ecosystem.config.js @@ -0,0 +1,22 @@ +module.exports = { + apps: [ + { + name: "ferrovia-panel", + script: "serve", + env: { + PM2_SERVE_PATH: './build', + PM2_SERVE_PORT: 4000, + PM2_SERVE_SPA: 'true', + PM2_SERVE_HOMEPAGE: '/index.html' + } + }, + { + name: "ferrovia-updater", + script: "updater.js", + env: { + UPDATER_PORT: 3001, + NODE_ENV: "production" + } + } + ] +}; diff --git a/migrations/add_perturbations_system.sql b/migrations/add_perturbations_system.sql index 6e65d5b..24ede74 100644 --- a/migrations/add_perturbations_system.sql +++ b/migrations/add_perturbations_system.sql @@ -15,7 +15,7 @@ BEGIN END $$; -- Exécuter le script principal -supabase_perturbations_tables.sql +-- supabase_perturbations_tables.sql a déjà été importé précédemment -- Vérification post-migration DO $$ diff --git a/package-lock.json b/package-lock.json index 862be9b..462ec83 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,6 +23,9 @@ "wcs-react": "^7.5.1", "web-vitals": "^2.1.4", "xlsx": "^0.18.5" + }, + "devDependencies": { + "supabase": "^2.115.0" } }, "node_modules/@adobe/css-tools": { @@ -2386,6 +2389,21 @@ "postcss-selector-parser": "^6.0.10" } }, + "node_modules/@ecies/ciphers": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/@ecies/ciphers/-/ciphers-0.2.6.tgz", + "integrity": "sha512-patgsRPKGkhhoBjETV4XxD0En4ui5fbX0hzayqI3M8tvNMGUoUvmyYAIWwlxBc1KX5cturfqByYdj5bYGRpN9g==", + "dev": true, + "license": "MIT", + "engines": { + "bun": ">=1", + "deno": ">=2.7.10", + "node": ">=16" + }, + "peerDependencies": { + "@noble/ciphers": "^1.0.0" + } + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.9.1", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", @@ -2909,6 +2927,48 @@ "node": ">=4.0" } }, + "node_modules/@noble/ciphers": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz", + "integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/curves": { + "version": "1.9.7", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.7.tgz", + "integrity": "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@noble/hashes": "1.8.0" + }, + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -3890,6 +3950,130 @@ "node": ">=22.0.0" } }, + "node_modules/@supabase/cli-darwin-arm64": { + "version": "2.115.0", + "resolved": "https://registry.npmjs.org/@supabase/cli-darwin-arm64/-/cli-darwin-arm64-2.115.0.tgz", + "integrity": "sha512-yUNw1KG+fyuBqBGvFT8ASC7aAkFkx2Kx+qPjUTW50ttiKKgg8D/eMSCoSjQuaAbL0vafw98bHEZ2dAfdyYyXMA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@supabase/cli-darwin-x64": { + "version": "2.115.0", + "resolved": "https://registry.npmjs.org/@supabase/cli-darwin-x64/-/cli-darwin-x64-2.115.0.tgz", + "integrity": "sha512-e4bbWADYcjSjXgSSErreoqyEeEjrFQunxCYummUoiGdVanck/itAIFwhtRykKpBtoKqCYh4CFxUiDxOFMBdNHQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@supabase/cli-linux-arm64": { + "version": "2.115.0", + "resolved": "https://registry.npmjs.org/@supabase/cli-linux-arm64/-/cli-linux-arm64-2.115.0.tgz", + "integrity": "sha512-JBcnnFuVekMR9+EOCcup1QihW+CHMBWcL/+N1Uz4HB6leX8d894VQ+sX0pFgbXLqnpGcc35IiXKwjLz4FCb8Dw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@supabase/cli-linux-arm64-musl": { + "version": "2.115.0", + "resolved": "https://registry.npmjs.org/@supabase/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.115.0.tgz", + "integrity": "sha512-2OCzD4qZx8RFbW2vfOlXpup+1UnVqSauUsSGH4mKiUIveMD/UyMI6Md8CLKLszwV2XwM+6bZG6w7d7Ems9vxCQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@supabase/cli-linux-x64": { + "version": "2.115.0", + "resolved": "https://registry.npmjs.org/@supabase/cli-linux-x64/-/cli-linux-x64-2.115.0.tgz", + "integrity": "sha512-ZvZ5QbPB3cvenEam6TDgngWPPm9GBO5m/5GYCOsqIfx2Gz5++WsEhlypWy9U4gTFae7ENtih0RqPzgiyWDKE+w==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@supabase/cli-linux-x64-musl": { + "version": "2.115.0", + "resolved": "https://registry.npmjs.org/@supabase/cli-linux-x64-musl/-/cli-linux-x64-musl-2.115.0.tgz", + "integrity": "sha512-t36QEEQxy0AsOn0rr1L8aEzZKYS+kRH4fvYr5KdB+rHA1x39A1dUeOCTqCucCr0V5oQ3+odJLlO6tinNczHKMQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@supabase/cli-windows-arm64": { + "version": "2.115.0", + "resolved": "https://registry.npmjs.org/@supabase/cli-windows-arm64/-/cli-windows-arm64-2.115.0.tgz", + "integrity": "sha512-MkYbWrNZXpWZxwglVaTexj+w6EWYUi34c7Y9SRGbcQBC/infteM/8zhVVRUmO3ltSEqnJCacHowLPISXIF6g/w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@supabase/cli-windows-x64": { + "version": "2.115.0", + "resolved": "https://registry.npmjs.org/@supabase/cli-windows-x64/-/cli-windows-x64-2.115.0.tgz", + "integrity": "sha512-jRXsJjbw/h0ssSpFQeClTTO2wtMw0YYS1yMzN2f/px4DehigBZNxSFB4thOtzmIJoisInm1gYV5pQJZla1Ytpw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@supabase/functions-js": { "version": "2.112.4", "resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.112.4.tgz", @@ -9574,6 +9758,24 @@ "safe-buffer": "~5.1.0" } }, + "node_modules/eciesjs": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/eciesjs/-/eciesjs-0.5.0.tgz", + "integrity": "sha512-s0J9SEVYAEPg7J63GFMApLYzPH9VNIQIyC6s15JpnqVc0TqcKWdbgFlnAweEBRyMmko2dcs2sfC83Hj4J43tuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ecies/ciphers": "^0.2.6", + "@noble/ciphers": "^1.3.0", + "@noble/curves": "^1.9.7", + "@noble/hashes": "^1.8.0" + }, + "engines": { + "bun": ">=1", + "deno": ">=2.7.10", + "node": ">=16" + } + }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -14566,6 +14768,16 @@ "jiti": "bin/jiti.js" } }, + "node_modules/jose": { + "version": "6.2.10", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.10.tgz", + "integrity": "sha512-iiW7J9qRFlGxvCOIBDBDxFePQSn7ZMAnrYGhrrOo6siO/MIqwfyilLR27pkfDgUk+raLuzADS8A3S/KLBisc0g==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/jquery": { "version": "3.7.1", "resolved": "https://registry.npmjs.org/jquery/-/jquery-3.7.1.tgz", @@ -21109,6 +21321,30 @@ "node": ">= 6" } }, + "node_modules/supabase": { + "version": "2.115.0", + "resolved": "https://registry.npmjs.org/supabase/-/supabase-2.115.0.tgz", + "integrity": "sha512-8fL9vOd6jOntmU8N5DVlHGE2GWR1r57ulsrOzSyO6IRYq5QMyKie8T8DH+hb+caGhYUVJLvmpY7XYwic60Uafg==", + "dev": true, + "license": "MIT", + "dependencies": { + "eciesjs": "^0.5.0", + "jose": "^6.2.8" + }, + "bin": { + "supabase": "dist/supabase.js" + }, + "optionalDependencies": { + "@supabase/cli-darwin-arm64": "2.115.0", + "@supabase/cli-darwin-x64": "2.115.0", + "@supabase/cli-linux-arm64": "2.115.0", + "@supabase/cli-linux-arm64-musl": "2.115.0", + "@supabase/cli-linux-x64": "2.115.0", + "@supabase/cli-linux-x64-musl": "2.115.0", + "@supabase/cli-windows-arm64": "2.115.0", + "@supabase/cli-windows-x64": "2.115.0" + } + }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", diff --git a/package.json b/package.json index f4506e5..8985c8a 100644 --- a/package.json +++ b/package.json @@ -42,5 +42,8 @@ "last 1 firefox version", "last 1 safari version" ] + }, + "devDependencies": { + "supabase": "^2.115.0" } } diff --git a/schema_migration_propre.sql b/schema_migration_propre.sql new file mode 100644 index 0000000..15c71a5 --- /dev/null +++ b/schema_migration_propre.sql @@ -0,0 +1,1300 @@ + +CREATE TYPE "public"."service_type" AS ENUM ( + 'TER', + 'TGV', + 'Intercités', + 'Fret' +); + +CREATE TYPE "public"."station_type" AS ENUM ( + 'interurbaine', + 'ville' +); + +CREATE TYPE "public"."transport_type" AS ENUM ( + 'bus', + 'tram', + 'metro', + 'tram-train', + 'train' +); + +CREATE OR REPLACE FUNCTION "public"."authenticate_user"("p_email" "text", "p_password" "text") RETURNS TABLE("id" "uuid", "email" "text", "nom" "text", "prenom" "text", "name" "text", "role" "text", "region_admin" boolean, "region_id" "text") + LANGUAGE "plpgsql" STABLE SECURITY DEFINER + AS $_$ +DECLARE + has_deleted_at boolean; + sql text; +BEGIN + SELECT EXISTS( + SELECT 1 FROM information_schema.columns + WHERE table_schema='public' AND table_name='utilisateurs' AND column_name='deleted_at' + ) INTO has_deleted_at; + + sql := 'SELECT u.id::uuid, u.email::text, u.nom::text, u.prenom::text, ' + || '(COALESCE(u.nom, '''') || '' '' || COALESCE(u.prenom, ''''))::text as name, ' + || 'u.role::text, ' + || 'COALESCE(u.region_admin, false)::boolean as region_admin, ' + || 'u.region_id::text ' + || 'FROM public.utilisateurs u ' + || 'WHERE lower(u.email) = lower($1) ' + || 'AND u.password_hash IS NOT NULL ' + || 'AND u.password_hash = crypt($2, u.password_hash) ' + || 'AND coalesce(u.actif, true) = true'; + + IF has_deleted_at THEN + sql := sql || ' AND u.deleted_at IS NULL'; + END IF; + + RETURN QUERY EXECUTE sql USING p_email, p_password; +END; +$_$; + +CREATE OR REPLACE FUNCTION "public"."create_user_with_password"("p_email" "text", "p_nom" "text", "p_prenom" "text", "p_role" "text" DEFAULT 'utilisateur'::"text", "p_actif" boolean DEFAULT true, "p_password" "text" DEFAULT NULL::"text", "p_photo_url" "text" DEFAULT NULL::"text", "p_region_admin" boolean DEFAULT false, "p_region_id" "text" DEFAULT NULL::"text") RETURNS TABLE("id" "uuid", "email" "text", "nom" "text", "prenom" "text", "role" "text", "actif" boolean, "photo_url" "text", "region_admin" boolean, "region_id" "text") + LANGUAGE "plpgsql" SECURITY DEFINER + AS $$ +DECLARE + new_id uuid; +BEGIN + INSERT INTO public.utilisateurs (email, nom, prenom, role, actif, password_hash, photo_url, region_admin, region_id, created_at) + VALUES ( + lower(p_email), + p_nom, + p_prenom, + p_role, + p_actif, + CASE WHEN p_password IS NOT NULL AND p_password != '' THEN crypt(p_password, gen_salt('bf')) ELSE NULL END, + p_photo_url, + p_region_admin, + p_region_id, + NOW() + ) + RETURNING utilisateurs.id INTO new_id; + + RETURN QUERY + SELECT u.id, u.email::text, u.nom::text, u.prenom::text, u.role::text, + u.actif, u.photo_url::text, u.region_admin, u.region_id::text + FROM public.utilisateurs u + WHERE u.id = new_id; +END; +$$; + +CREATE OR REPLACE FUNCTION "public"."get_gare_details"("p_gare_id" "uuid") RETURNS TABLE("id" "uuid", "nom" "text", "type" "public"."station_type", "services" "public"."service_type"[], "quais" "jsonb", "transports" "jsonb", "created_at" timestamp with time zone, "updated_at" timestamp with time zone) + LANGUAGE "plpgsql" STABLE SECURITY DEFINER + AS $$ +BEGIN +RETURN QUERY +SELECT + g.id, + g.nom, + g.type, + g.services, + COALESCE( + (SELECT jsonb_agg(jsonb_build_object('id', q.id, 'nom', q.nom, 'distance_metres', q.distance_metres)) + FROM public.gare_quais q WHERE q.gare_id = g.id), + '[]'::jsonb + ) as quais, + COALESCE( + (SELECT jsonb_agg(jsonb_build_object('id', t.id, 'type', t.type, 'ligne', t.ligne)) + FROM public.gare_transports t WHERE t.gare_id = g.id), + '[]'::jsonb + ) as transports, + g.created_at, + g.updated_at +FROM public.gares g +WHERE g.id = p_gare_id AND g.deleted_at IS NULL; +END; +$$; + +CREATE OR REPLACE FUNCTION "public"."get_station_details"("p_station_id" "uuid") RETURNS TABLE("id" "uuid", "nom" "text", "type" "public"."station_type", "services" "public"."service_type"[], "quais" "jsonb", "transports" "jsonb", "created_at" timestamp with time zone, "updated_at" timestamp with time zone) + LANGUAGE "plpgsql" STABLE SECURITY DEFINER + AS $$ +BEGIN + RETURN QUERY + SELECT + s.id, + s.nom, + s.type, + s.services, + COALESCE( + (SELECT jsonb_agg(jsonb_build_object('id', q.id, 'nom', q.nom, 'distance_metres', q.distance_metres)) + FROM public.station_quais q WHERE q.station_id = s.id), + '[]'::jsonb + ) as quais, + COALESCE( + (SELECT jsonb_agg(jsonb_build_object('id', t.id, 'type', t.type, 'ligne', t.ligne)) + FROM public.station_transports t WHERE t.station_id = s.id), + '[]'::jsonb + ) as transports, + s.created_at, + s.updated_at + FROM public.stations s + WHERE s.id = p_station_id AND s.deleted_at IS NULL; +END; +$$; + +CREATE OR REPLACE FUNCTION "public"."handle_updated_at"() RETURNS "trigger" + LANGUAGE "plpgsql" + AS $$ +BEGIN + NEW.updated_at = NOW(); + RETURN NEW; +END; +$$; + +CREATE OR REPLACE FUNCTION "public"."import_gtfs_rpc"("payload" json) RETURNS json + LANGUAGE "plpgsql" + AS $$ +DECLARE + res json := json_build_object('status', 'ok', 'created', 0, 'warnings', json_build_array()); +BEGIN + -- Exemple minimal : cette fonction doit être étendue selon vos règles métier. + -- Ici on ne fait rien, juste renvoyer un résultat. + RETURN res; +EXCEPTION WHEN OTHERS THEN + RETURN json_build_object('status', 'error', 'message', SQLERRM); +END; +$$; + +CREATE OR REPLACE FUNCTION "public"."log_audit"() RETURNS "trigger" + LANGUAGE "plpgsql" SECURITY DEFINER + AS $$ +DECLARE + v_changed_by_text text; + v_changed_by uuid; +BEGIN + v_changed_by_text := current_setting('jwt.claims.user_id', true); + IF v_changed_by_text IS NOT NULL AND v_changed_by_text <> '' THEN + BEGIN + v_changed_by := v_changed_by_text::uuid; + EXCEPTION WHEN others THEN + v_changed_by := NULL; + END; + ELSE + v_changed_by := NULL; + END IF; + + IF (TG_OP = 'INSERT') THEN + INSERT INTO public.audit_logs(table_name, operation, record_id, changed_by, new_data, changed_at) + VALUES (TG_TABLE_NAME, 'INSERT', NEW.id, v_changed_by, row_to_json(NEW)::jsonb, now()); + RETURN NEW; + ELSIF (TG_OP = 'UPDATE') THEN + INSERT INTO public.audit_logs(table_name, operation, record_id, changed_by, old_data, new_data, changed_at) + VALUES (TG_TABLE_NAME, 'UPDATE', NEW.id, v_changed_by, row_to_json(OLD)::jsonb, row_to_json(NEW)::jsonb, now()); + RETURN NEW; + ELSIF (TG_OP = 'DELETE') THEN + INSERT INTO public.audit_logs(table_name, operation, record_id, changed_by, old_data, changed_at) + VALUES (TG_TABLE_NAME, 'DELETE', OLD.id, v_changed_by, row_to_json(OLD)::jsonb, now()); + RETURN OLD; + END IF; + RETURN NULL; +END; +$$; + +CREATE OR REPLACE FUNCTION "public"."set_updated_at"() RETURNS "trigger" + LANGUAGE "plpgsql" + AS $$ +BEGIN + NEW.updated_at = now(); + RETURN NEW; +END; +$$; + +CREATE OR REPLACE FUNCTION "public"."update_travaux_updated_at"() RETURNS "trigger" + LANGUAGE "plpgsql" + AS $$ +BEGIN + NEW.updated_at = now(); + RETURN NEW; +END; +$$; + +CREATE OR REPLACE FUNCTION "public"."update_updated_at_column"() RETURNS "trigger" + LANGUAGE "plpgsql" + AS $$ +BEGIN + NEW.updated_at = NOW(); + RETURN NEW; +END; +$$; + +CREATE OR REPLACE FUNCTION "public"."update_user_password"("p_user_id" "uuid", "p_password" "text") RETURNS "void" + LANGUAGE "plpgsql" SECURITY DEFINER + AS $$ +BEGIN + IF p_password IS NULL OR p_password = '' THEN + RAISE EXCEPTION 'Le mot de passe ne peut pas être vide'; + END IF; + + UPDATE public.utilisateurs + SET password_hash = crypt(p_password, gen_salt('bf')), + updated_at = NOW() + WHERE id = p_user_id; + + IF NOT FOUND THEN + RAISE EXCEPTION 'Utilisateur non trouvé'; + END IF; +END; +$$; + +CREATE OR REPLACE FUNCTION "public"."update_utilisateurs_updated_at"() RETURNS "trigger" + LANGUAGE "plpgsql" + AS $$ +BEGIN + NEW.updated_at = NOW(); + RETURN NEW; +END; +$$; + +CREATE TABLE IF NOT EXISTS "public"."actualites" ( + "id" "uuid" DEFAULT "gen_random_uuid"() NOT NULL, + "titre" character varying(255) NOT NULL, + "contenu" "text" NOT NULL, + "resume" character varying(500), + "categorie" character varying(50), + "image_url" character varying(500), + "date_publication" timestamp with time zone DEFAULT "now"() NOT NULL, + "auteur" character varying(100), + "actif" boolean DEFAULT true, + "mise_en_avant" boolean DEFAULT false, + "created_at" timestamp with time zone DEFAULT "now"(), + "updated_at" timestamp with time zone DEFAULT "now"(), + "deleted_at" timestamp with time zone, + "region_id" character varying(100), + "created_by" "uuid", + "image_couverture" "text", + CONSTRAINT "actualites_categorie_check" CHECK ((("categorie")::"text" = ANY ((ARRAY['general'::character varying, 'service'::character varying, 'promotion'::character varying, 'partenariat'::character varying, 'autre'::character varying])::"text"[]))) +); + +COMMENT ON TABLE "public"."actualites" IS 'Actualités et nouvelles du réseau'; + +COMMENT ON COLUMN "public"."actualites"."region_id" IS 'Identifiant de la région concernée par l''actualité'; + +COMMENT ON COLUMN "public"."actualites"."created_by" IS 'Utilisateur ayant créé l''actualité'; + +CREATE TABLE IF NOT EXISTS "public"."audit_logs" ( + "id" bigint NOT NULL, + "table_name" "text" NOT NULL, + "operation" "text" NOT NULL, + "record_id" "uuid", + "changed_by" "uuid", + "old_data" "jsonb", + "new_data" "jsonb", + "changed_at" timestamp with time zone DEFAULT "now"() NOT NULL +); + +CREATE SEQUENCE IF NOT EXISTS "public"."audit_logs_id_seq" + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + +ALTER SEQUENCE "public"."audit_logs_id_seq" OWNED BY "public"."audit_logs"."id"; + +CREATE TABLE IF NOT EXISTS "public"."evenements" ( + "id" "uuid" DEFAULT "gen_random_uuid"() NOT NULL, + "titre" character varying(255) NOT NULL, + "description" "text", + "lieu" character varying(255), + "gare_id" "uuid", + "date_debut" timestamp with time zone NOT NULL, + "date_fin" timestamp with time zone, + "image_url" character varying(500), + "lien_externe" character varying(500), + "actif" boolean DEFAULT true, + "created_at" timestamp with time zone DEFAULT "now"(), + "updated_at" timestamp with time zone DEFAULT "now"(), + "deleted_at" timestamp with time zone, + "region_id" character varying(100), + "created_by" "uuid", + "visible_site" boolean DEFAULT true +); + +COMMENT ON TABLE "public"."evenements" IS 'Événements à venir dans les gares ou sur le réseau'; + +COMMENT ON COLUMN "public"."evenements"."region_id" IS 'Identifiant de la région concernée par l''événement'; + +COMMENT ON COLUMN "public"."evenements"."created_by" IS 'Utilisateur ayant créé l''événement'; + +CREATE TABLE IF NOT EXISTS "public"."gare_quais" ( + "id" "uuid" DEFAULT "gen_random_uuid"() NOT NULL, + "gare_id" "uuid" NOT NULL, + "nom" "text" NOT NULL, + "distance_metres" integer DEFAULT 0 NOT NULL, + "created_at" timestamp with time zone DEFAULT "now"() NOT NULL +); + +CREATE TABLE IF NOT EXISTS "public"."gare_transports" ( + "id" "uuid" DEFAULT "gen_random_uuid"() NOT NULL, + "gare_id" "uuid" NOT NULL, + "type" "public"."transport_type" NOT NULL, + "ligne" "text", + "created_at" timestamp with time zone DEFAULT "now"() NOT NULL +); + +CREATE TABLE IF NOT EXISTS "public"."gares" ( + "id" "uuid" DEFAULT "gen_random_uuid"() NOT NULL, + "nom" "text" NOT NULL, + "type" "public"."station_type" DEFAULT 'ville'::"public"."station_type" NOT NULL, + "services" "public"."service_type"[] DEFAULT '{}'::"public"."service_type"[] NOT NULL, + "created_at" timestamp with time zone DEFAULT "now"() NOT NULL, + "updated_at" timestamp with time zone DEFAULT "now"() NOT NULL, + "deleted_at" timestamp with time zone, + "informations" "jsonb" DEFAULT '{}'::"jsonb" +); + +COMMENT ON COLUMN "public"."gares"."informations" IS 'Informations enrichies de la gare au format JSON. Structure : + { + -- Informations Générales + "adresse": "20 Bd Diderot, 75012 Paris", + "horaires_guichet": "6h00 - 21h30", + "horaires_gare": "5h00 - 00h30", + "distributeur_ter": true, + + -- Accessibilité + "service_assistance": true, + + -- Services + "espace_attente": true, + "distributeur_tgv": false, + "objets_trouves": true, + "toilettes": true, + "wifi_gratuit": true, + "photomaton": false, + "defibrillateur": true, + "galeries_marchandes": false, + + -- Voiture + "depose_minute": true, + "espace_taxis": true, + "espace_vtc": false, + "location_voiture": false, + "parking": true, + + -- Vélo + "velo_libre_service": true, + "parking_velos": true, + + -- Transports en commun enrichis + "lignes_bus": [{"nom": "29", "terminus_a": "Gare Saint-Lazare", "terminus_b": "Montempoivre"}], + "lignes_metro": [{"nom": "1", "terminus_a": "La Défense", "terminus_b": "Château de Vincennes"}], + "lignes_tramway": [], + "lignes_car_regional": [] + }'; + +CREATE TABLE IF NOT EXISTS "public"."horaires" ( + "id" "uuid" DEFAULT "gen_random_uuid"() NOT NULL, + "numero_train" character varying(50) NOT NULL, + "type_train" character varying(255), + "type_train_id" bigint, + "gare_depart_id" "uuid", + "gare_depart_nom" character varying(255), + "heure_depart" time without time zone, + "gare_arrivee_id" "uuid", + "gare_arrivee_nom" character varying(255), + "heure_arrivee" time without time zone, + "circule_lundi" boolean DEFAULT true, + "circule_mardi" boolean DEFAULT true, + "circule_mercredi" boolean DEFAULT true, + "circule_jeudi" boolean DEFAULT true, + "circule_vendredi" boolean DEFAULT true, + "circule_samedi" boolean DEFAULT true, + "circule_dimanche" boolean DEFAULT true, + "circule_jours_feries" boolean DEFAULT false, + "circule_dimanches_feries" boolean DEFAULT false, + "jours_personnalises" "jsonb" DEFAULT '[]'::"jsonb", + "jours_non_circulation" "jsonb" DEFAULT '[]'::"jsonb", + "materiel_roulant_id" bigint, + "ligne_id" "uuid", + "ligne_nom" character varying(255), + "service_annuel_id" "uuid", + "service_annuel_nom" character varying(255), + "est_substitution" boolean DEFAULT false, + "motif_substitution" character varying(255), + "actif" boolean DEFAULT true, + "created_at" timestamp with time zone DEFAULT "now"(), + "updated_at" timestamp with time zone DEFAULT "now"(), + "deleted_at" timestamp with time zone, + "gares_desservies" "jsonb" DEFAULT '[]'::"jsonb", + "composition_train" "jsonb" DEFAULT '[]'::"jsonb", + "substitution_disponible" boolean DEFAULT false, + "region_id" character varying(100) +); + +COMMENT ON TABLE "public"."horaires" IS 'Table principale des horaires (sillons) de trains'; + +COMMENT ON COLUMN "public"."horaires"."numero_train" IS 'Numéro unique du train (ex: TER 12345)'; + +COMMENT ON COLUMN "public"."horaires"."type_train" IS 'Type de train (TER, TGV, Intercités, etc.)'; + +COMMENT ON COLUMN "public"."horaires"."type_train_id" IS 'Référence vers la table types_train pour le type de train'; + +COMMENT ON COLUMN "public"."horaires"."jours_personnalises" IS 'Dates spécifiques de circulation au format JSON'; + +COMMENT ON COLUMN "public"."horaires"."jours_non_circulation" IS 'Dates où le train ne circule pas au format JSON'; + +COMMENT ON COLUMN "public"."horaires"."ligne_id" IS 'Identifiant de la ligne associée au sillon'; + +COMMENT ON COLUMN "public"."horaires"."ligne_nom" IS 'Nom de la ligne associée (copie pour lecture)'; + +COMMENT ON COLUMN "public"."horaires"."service_annuel_id" IS 'Identifiant du service annuel associé (SA)'; + +COMMENT ON COLUMN "public"."horaires"."service_annuel_nom" IS 'Nom du service annuel associé (copie pour lecture)'; + +COMMENT ON COLUMN "public"."horaires"."est_substitution" IS 'Indique si ce sillon est une substitution autocar'; + +COMMENT ON COLUMN "public"."horaires"."motif_substitution" IS 'Motif de la substitution (travaux, etc.)'; + +COMMENT ON COLUMN "public"."horaires"."gares_desservies" IS 'Snapshot JSON des gares desservies (copie dénormalisée synchronisée avec horaires_gares_desservies). Format : [{"gare_id": "uuid", "gare_nom": "Nom Gare", "heure_arrivee": "HH:MM", "heure_depart": "HH:MM", "ordre": 1}, ...]'; + +COMMENT ON COLUMN "public"."horaires"."composition_train" IS 'Composition ordonnée du train : liste JSON des matériels roulants affectés au sillon. Format : [{"materiel_id": 1, "materiel_nom": "Régiolis", "materiel_nom_technique": "X73500", "materiel_image_url": "https://...", "position": 1}, ...]'; + +COMMENT ON COLUMN "public"."horaires"."substitution_disponible" IS 'Indique si cet horaire est disponible comme sillon de substitution'; + +COMMENT ON COLUMN "public"."horaires"."region_id" IS 'Identifiant de la région associée à cet horaire'; + +CREATE TABLE IF NOT EXISTS "public"."horaires_gares_desservies" ( + "id" bigint NOT NULL, + "gare_id" "uuid", + "gare_nom" character varying(255) NOT NULL, + "heure_arrivee" time without time zone, + "heure_depart" time without time zone, + "ordre" integer DEFAULT 0 NOT NULL, + "created_at" timestamp with time zone DEFAULT "now"(), + "updated_at" timestamp with time zone DEFAULT "now"(), + "horaire_id" "uuid", + "quai" character varying(50) DEFAULT NULL::character varying, + "quai_id" "uuid" +); + +COMMENT ON TABLE "public"."horaires_gares_desservies" IS 'Gares intermédiaires desservies par chaque horaire'; + +COMMENT ON COLUMN "public"."horaires_gares_desservies"."ordre" IS 'Position de la gare dans le parcours (1 = première après départ)'; + +COMMENT ON COLUMN "public"."horaires_gares_desservies"."quai" IS 'Numéro ou nom du quai attribué à cet arrêt (ex: "Voie 3", "Quai A")'; + +CREATE SEQUENCE IF NOT EXISTS "public"."horaires_gares_desservies_id_seq" + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + +ALTER SEQUENCE "public"."horaires_gares_desservies_id_seq" OWNED BY "public"."horaires_gares_desservies"."id"; + +CREATE TABLE IF NOT EXISTS "public"."info_trafic" ( + "id" "uuid" DEFAULT "gen_random_uuid"() NOT NULL, + "titre" character varying(255) NOT NULL, + "description" "text", + "type" character varying(50) NOT NULL, + "severite" character varying(20) NOT NULL, + "ligne" character varying(100), + "gare_id" "uuid", + "date_debut" timestamp with time zone DEFAULT "now"() NOT NULL, + "date_fin" timestamp with time zone, + "actif" boolean DEFAULT true, + "created_at" timestamp with time zone DEFAULT "now"(), + "updated_at" timestamp with time zone DEFAULT "now"(), + "deleted_at" timestamp with time zone, + "region_id" character varying(100), + "created_by" "uuid", + CONSTRAINT "info_trafic_severite_check" CHECK ((("severite")::"text" = ANY ((ARRAY['info'::character varying, 'warning'::character varying, 'critical'::character varying])::"text"[]))), + CONSTRAINT "info_trafic_type_check" CHECK ((("type")::"text" = ANY ((ARRAY['perturbation'::character varying, 'travaux'::character varying, 'greve'::character varying, 'meteo'::character varying, 'autre'::character varying])::"text"[]))) +); + +COMMENT ON TABLE "public"."info_trafic" IS 'Informations de trafic (perturbations, travaux, etc.)'; + +COMMENT ON COLUMN "public"."info_trafic"."region_id" IS 'Identifiant de la région concernée par l''info trafic'; + +COMMENT ON COLUMN "public"."info_trafic"."created_by" IS 'Utilisateur ayant créé l''info trafic'; + +CREATE TABLE IF NOT EXISTS "public"."lignes" ( + "id" "uuid" DEFAULT "gen_random_uuid"() NOT NULL, + "nom" character varying(255) NOT NULL, + "code" character varying(50), + "gare_depart_id" "uuid", + "gare_arrivee_id" "uuid", + "actif" boolean DEFAULT true, + "created_at" timestamp with time zone DEFAULT "now"(), + "updated_at" timestamp with time zone DEFAULT "now"(), + "deleted_at" timestamp with time zone, + "fh_url" "text", + "region" character varying(100) +); + +COMMENT ON TABLE "public"."lignes" IS 'Lignes de train avec gares de départ et d''arrivée'; + +COMMENT ON COLUMN "public"."lignes"."fh_url" IS 'URL publique du fichier PDF de la fiche horaire de la ligne (stocké dans Supabase Storage)'; + +CREATE TABLE IF NOT EXISTS "public"."lignes_gares" ( + "id" "uuid" DEFAULT "gen_random_uuid"() NOT NULL, + "ligne_id" "uuid", + "gare_id" "uuid", + "ordre" integer NOT NULL, + "desserte" character varying(20) NOT NULL, + "created_at" timestamp with time zone DEFAULT "now"(), + CONSTRAINT "lignes_gares_desserte_check" CHECK ((("desserte")::"text" = ANY ((ARRAY['tous'::character varying, 'partielle'::character varying])::"text"[]))) +); + +COMMENT ON TABLE "public"."lignes_gares" IS 'Gares présentes sur le tracé d''une ligne avec type de desserte'; + +CREATE TABLE IF NOT EXISTS "public"."materiel-roulant" ( + "id" bigint NOT NULL, + "nom" "text", + "nom_technique" "text", + "capacite" integer, + "image_url" "text", + "type_train" "text", + "numero_serie" "text", + "created_at" timestamp with time zone DEFAULT "now"(), + "updated_at" timestamp with time zone +); + +ALTER TABLE "public"."materiel-roulant" ALTER COLUMN "id" ADD GENERATED ALWAYS AS IDENTITY ( + SEQUENCE NAME "public"."materiel-roulant_id_seq" + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1 +); + +CREATE TABLE IF NOT EXISTS "public"."menus_regionaux" ( + "id" "uuid" DEFAULT "gen_random_uuid"() NOT NULL, + "nom" character varying(200) NOT NULL, + "description" "text", + "actif" boolean DEFAULT false, + "structure" "jsonb" DEFAULT '[]'::"jsonb", + "created_at" timestamp with time zone DEFAULT "now"(), + "updated_at" timestamp with time zone DEFAULT "now"(), + "region_id" character varying(64) +); + +CREATE TABLE IF NOT EXISTS "public"."messagerie_messages" ( + "id" "uuid" DEFAULT "gen_random_uuid"() NOT NULL, + "thread_id" "uuid" NOT NULL, + "sender_id" "uuid" NOT NULL, + "contenu" "text" NOT NULL, + "created_at" timestamp with time zone DEFAULT "now"() NOT NULL +); + +CREATE TABLE IF NOT EXISTS "public"."messagerie_participants" ( + "id" "uuid" DEFAULT "gen_random_uuid"() NOT NULL, + "thread_id" "uuid" NOT NULL, + "user_id" "uuid" NOT NULL, + "created_at" timestamp with time zone DEFAULT "now"() NOT NULL +); + +CREATE TABLE IF NOT EXISTS "public"."messagerie_threads" ( + "id" "uuid" DEFAULT "gen_random_uuid"() NOT NULL, + "created_at" timestamp with time zone DEFAULT "now"() NOT NULL, + "updated_at" timestamp with time zone DEFAULT "now"() NOT NULL +); + +CREATE TABLE IF NOT EXISTS "public"."page_personnalisation" ( + "id" "uuid" DEFAULT "gen_random_uuid"() NOT NULL, + "page" "text" NOT NULL, + "config" "jsonb" DEFAULT '{}'::"jsonb", + "created_at" timestamp with time zone DEFAULT "now"(), + "updated_at" timestamp with time zone DEFAULT "now"() +); + +CREATE TABLE IF NOT EXISTS "public"."parametres" ( + "id" "uuid" DEFAULT "gen_random_uuid"() NOT NULL, + "cle" character varying(100) NOT NULL, + "valeur" "jsonb", + "created_at" timestamp with time zone DEFAULT "now"(), + "updated_at" timestamp with time zone DEFAULT "now"() +); + +CREATE TABLE IF NOT EXISTS "public"."perturbations" ( + "id" "uuid" DEFAULT "gen_random_uuid"() NOT NULL, + "horaire_id" "uuid" NOT NULL, + "type_perturbation" character varying(50) NOT NULL, + "duree_retard_minutes" integer, + "cause" character varying(100) NOT NULL, + "cause_details" "text", + "sillon_substitution_id" "uuid", + "date_debut" timestamp with time zone DEFAULT "now"() NOT NULL, + "date_fin" timestamp with time zone, + "actif" boolean DEFAULT true, + "notes" "text", + "created_at" timestamp with time zone DEFAULT "now"(), + "updated_at" timestamp with time zone DEFAULT "now"(), + "deleted_at" timestamp with time zone, + "region_id" character varying(100), + "created_by" "uuid", + "perturbations_arrets" "jsonb", + "jours_perturbation" "jsonb", + CONSTRAINT "perturbations_cause_check" CHECK ((("cause")::"text" = ANY ((ARRAY['incident_technique'::character varying, 'accident_personne'::character varying, 'conditions_meteorologiques'::character varying, 'travaux'::character varying, 'mouvement_social'::character varying, 'intervention_police'::character varying, 'affluence'::character varying, 'malveillance'::character varying, 'autre'::character varying])::"text"[]))), + CONSTRAINT "perturbations_type_perturbation_check" CHECK ((("type_perturbation")::"text" = ANY ((ARRAY['retard'::character varying, 'suppression'::character varying, 'modification_parcours'::character varying])::"text"[]))) +); + +COMMENT ON TABLE "public"."perturbations" IS 'Table des perturbations affectant les horaires de trains'; + +COMMENT ON COLUMN "public"."perturbations"."type_perturbation" IS 'Type de perturbation: retard, suppression, ou modification_parcours'; + +COMMENT ON COLUMN "public"."perturbations"."duree_retard_minutes" IS 'Durée du retard en minutes (applicable uniquement pour type=retard)'; + +COMMENT ON COLUMN "public"."perturbations"."cause" IS 'Cause de la perturbation'; + +COMMENT ON COLUMN "public"."perturbations"."sillon_substitution_id" IS 'Train de substitution proposé (si suppression ou modification)'; + +COMMENT ON COLUMN "public"."perturbations"."region_id" IS 'Identifiant de la région concernée par la perturbation'; + +COMMENT ON COLUMN "public"."perturbations"."created_by" IS 'Utilisateur ayant créé la perturbation'; + +COMMENT ON COLUMN "public"."perturbations"."perturbations_arrets" IS 'Snapshot complet des perturbations par arrêt (état du formulaire). Format : [{ + "index": 0, + "gare_nom": "Paris Gare de Lyon", + "heure_arrivee": null, + "heure_depart": "07:30", + "type_perturbation": "retard", + "retard_minutes": 15, + "retard_cumule": 0, + "arret_prolonge_minutes": 0, + "cause_arret": "Incident technique", + "is_nouveau_terminus": false, + "is_nouvelle_origine": false, + "isFirst": true, + "isLast": false, + "manual_retard": true, + "manual_arret_prolonge": false + }, ...]'; + +COMMENT ON COLUMN "public"."perturbations"."jours_perturbation" IS 'Liste des jours/périodes de perturbation. Format : [{type: "jour"|"periode", date: "YYYY-MM-DD", dateDebut: "YYYY-MM-DD", dateFin: "YYYY-MM-DD"}]'; + +CREATE TABLE IF NOT EXISTS "public"."perturbations_modifications_parcours" ( + "id" "uuid" DEFAULT "gen_random_uuid"() NOT NULL, + "perturbation_id" "uuid" NOT NULL, + "type_modification" character varying(20) NOT NULL, + "gare_id" "uuid", + "gare_nom" character varying(255) NOT NULL, + "nouvelle_heure_arrivee" time without time zone, + "nouvelle_heure_depart" time without time zone, + "ordre" integer, + "created_at" timestamp with time zone DEFAULT "now"(), + CONSTRAINT "perturbations_modifications_parcours_type_modification_check" CHECK ((("type_modification")::"text" = ANY ((ARRAY['gare_supprimee'::character varying, 'gare_ajoutee'::character varying, 'horaire_modifie'::character varying])::"text"[]))) +); + +COMMENT ON TABLE "public"."perturbations_modifications_parcours" IS 'Détails des modifications de parcours pour les perturbations'; + +CREATE TABLE IF NOT EXISTS "public"."profils_utilisateurs" ( + "id" "uuid" NOT NULL, + "email" "text" NOT NULL, + "prenom" "text", + "nom" "text", + "date_naissance" "date", + "telephone" "text", + "carte_reduction" "text", + "gares_favorites" "text"[] DEFAULT '{}'::"text"[], + "lignes_favorites" "text"[] DEFAULT '{}'::"text"[], + "created_at" timestamp with time zone DEFAULT "now"(), + "updated_at" timestamp with time zone DEFAULT "now"(), + "cartes_bancaires" "jsonb" DEFAULT '[]'::"jsonb", + "mode_paiement_favori" "text", + "commandes" "jsonb" DEFAULT '[]'::"jsonb", + "panier" "jsonb" DEFAULT '[]'::"jsonb", + "trajets_favoris" "text"[] DEFAULT '{}'::"text"[] +); + +COMMENT ON COLUMN "public"."profils_utilisateurs"."cartes_bancaires" IS 'Tableau JSON de cartes bancaires enregistrées. Chaque objet : {id, label, last4, expiry, brand, is_favorite}. Ne stocke JAMAIS le numéro complet.'; + +COMMENT ON COLUMN "public"."profils_utilisateurs"."mode_paiement_favori" IS 'Mode de paiement préféré : carte, paypal, apple_pay, google_pay ou null.'; + +COMMENT ON COLUMN "public"."profils_utilisateurs"."commandes" IS 'Historique des commandes. Chaque objet = commande complète avec items, passager, total, date et statut.'; + +COMMENT ON COLUMN "public"."profils_utilisateurs"."panier" IS 'Panier en cours. Tableau JSON d''items non encore payés.'; + +COMMENT ON COLUMN "public"."profils_utilisateurs"."trajets_favoris" IS 'Trajets favoris de l''utilisateur (ex: Dijon → Besançon).'; + +CREATE TABLE IF NOT EXISTS "public"."services_annuels" ( + "id" "uuid" DEFAULT "gen_random_uuid"() NOT NULL, + "nom" character varying(255) NOT NULL, + "code" character varying(50), + "date_debut" "date" NOT NULL, + "date_fin" "date" NOT NULL, + "description" "text", + "actif" boolean DEFAULT true, + "created_at" timestamp with time zone DEFAULT "now"(), + "updated_at" timestamp with time zone DEFAULT "now"(), + "deleted_at" timestamp with time zone +); + +COMMENT ON TABLE "public"."services_annuels" IS 'Services annuels attribuables aux horaires'; + +CREATE TABLE IF NOT EXISTS "public"."tarifs" ( + "id" integer NOT NULL, + "nom" character varying(255) NOT NULL, + "type" character varying(50) NOT NULL, + "description" "text", + "prix_minimum" numeric(10,2), + "prix_par_km" numeric(10,3), + "age_maximum" integer, + "regions" "text"[] NOT NULL, + "date_debut" "date", + "date_fin" "date", + "created_at" timestamp without time zone DEFAULT "now"(), + "updated_at" timestamp without time zone DEFAULT "now"(), + "ordre" integer DEFAULT 0 NOT NULL, + "page_dediee" boolean DEFAULT false, + "infos" "jsonb" DEFAULT '{}'::"jsonb" +); + +CREATE SEQUENCE IF NOT EXISTS "public"."tarifs_id_seq" + AS integer + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + +ALTER SEQUENCE "public"."tarifs_id_seq" OWNED BY "public"."tarifs"."id"; + +CREATE TABLE IF NOT EXISTS "public"."travaux" ( + "id" "uuid" DEFAULT "gen_random_uuid"() NOT NULL, + "titre" "text" NOT NULL, + "description" "text", + "date_debut" "date" NOT NULL, + "date_fin" "date" NOT NULL, + "jours_travaux" "text"[] NOT NULL, + "heure_debut" time without time zone NOT NULL, + "heure_fin" time without time zone NOT NULL, + "actif" boolean DEFAULT true, + "created_at" timestamp with time zone DEFAULT "now"(), + "updated_at" timestamp with time zone DEFAULT "now"(), + "deleted_at" timestamp with time zone, + "ligne_id" "uuid", + "region_id" character varying(100), + "created_by" "uuid", + "substitutions_attribuees" "jsonb" DEFAULT '[]'::"jsonb", + "ligne_nom" "text", + CONSTRAINT "valid_dates" CHECK (("date_fin" >= "date_debut")), + CONSTRAINT "valid_hours" CHECK (("heure_fin" > "heure_debut")) +); + +COMMENT ON TABLE "public"."travaux" IS 'Table principale pour la gestion des travaux sur les lignes'; + +COMMENT ON COLUMN "public"."travaux"."jours_travaux" IS 'Jours de la semaine où les travaux ont lieu'; + +COMMENT ON COLUMN "public"."travaux"."actif" IS 'Indique si les travaux sont actifs'; + +COMMENT ON COLUMN "public"."travaux"."deleted_at" IS 'Date de suppression (soft delete)'; + +COMMENT ON COLUMN "public"."travaux"."region_id" IS 'Identifiant de la région concernée par les travaux'; + +COMMENT ON COLUMN "public"."travaux"."created_by" IS 'Utilisateur ayant créé les travaux'; + +CREATE TABLE IF NOT EXISTS "public"."travaux_substitutions" ( + "id" "uuid" DEFAULT "gen_random_uuid"() NOT NULL, + "travaux_id" "uuid" NOT NULL, + "horaire_id" "uuid" NOT NULL, + "ordre" integer DEFAULT 0, + "created_at" timestamp with time zone DEFAULT "now"() +); + +COMMENT ON TABLE "public"."travaux_substitutions" IS 'Table de liaison entre travaux et horaires de substitution'; + +COMMENT ON COLUMN "public"."travaux_substitutions"."ordre" IS 'Ordre d''affichage des substitutions'; + +CREATE TABLE IF NOT EXISTS "public"."types_train" ( + "id" bigint NOT NULL, + "nom" character varying(255) NOT NULL, + "code" character varying(50), + "description" "text", + "logo_url" "text", + "couleur" character varying(20) DEFAULT '#0074d9'::character varying, + "created_at" timestamp with time zone DEFAULT "now"(), + "updated_at" timestamp with time zone DEFAULT "now"(), + "deleted_at" timestamp with time zone +); + +COMMENT ON TABLE "public"."types_train" IS 'Types de train configurables avec logos et couleurs'; + +CREATE SEQUENCE IF NOT EXISTS "public"."types_train_id_seq" + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + +ALTER SEQUENCE "public"."types_train_id_seq" OWNED BY "public"."types_train"."id"; + +CREATE TABLE IF NOT EXISTS "public"."utilisateurs" ( + "id" "uuid" DEFAULT "gen_random_uuid"() NOT NULL, + "email" character varying(255) NOT NULL, + "nom" character varying(100) NOT NULL, + "prenom" character varying(100) NOT NULL, + "role" character varying(50) DEFAULT 'utilisateur'::character varying NOT NULL, + "actif" boolean DEFAULT true NOT NULL, + "password_hash" character varying(255), + "photo_url" "text", + "region_admin" boolean DEFAULT false, + "region_nom" character varying(255), + "created_at" timestamp with time zone DEFAULT "now"(), + "updated_at" timestamp with time zone DEFAULT "now"(), + "region_id" character varying(100), + "permissions" "jsonb" DEFAULT '{}'::"jsonb" NOT NULL +); + +COMMENT ON TABLE "public"."utilisateurs" IS 'Utilisateurs de l''application avec leurs informations et rôles'; + +COMMENT ON COLUMN "public"."utilisateurs"."id" IS 'Identifiant unique de l''utilisateur'; + +COMMENT ON COLUMN "public"."utilisateurs"."email" IS 'Adresse email de l''utilisateur (unique)'; + +COMMENT ON COLUMN "public"."utilisateurs"."nom" IS 'Nom de famille de l''utilisateur'; + +COMMENT ON COLUMN "public"."utilisateurs"."prenom" IS 'Prénom de l''utilisateur'; + +COMMENT ON COLUMN "public"."utilisateurs"."role" IS 'Rôle de l''utilisateur (admin, gestionnaire, utilisateur)'; + +COMMENT ON COLUMN "public"."utilisateurs"."actif" IS 'Indique si l''utilisateur est actif ou désactivé'; + +COMMENT ON COLUMN "public"."utilisateurs"."password_hash" IS 'Hash du mot de passe (ne pas stocker en clair)'; + +COMMENT ON COLUMN "public"."utilisateurs"."photo_url" IS 'URL de la photo de profil'; + +COMMENT ON COLUMN "public"."utilisateurs"."region_admin" IS 'Indique si l''utilisateur administre une région'; + +COMMENT ON COLUMN "public"."utilisateurs"."region_nom" IS 'Nom de la région administrée (si applicable)'; + +COMMENT ON COLUMN "public"."utilisateurs"."created_at" IS 'Date de création de l''utilisateur'; + +COMMENT ON COLUMN "public"."utilisateurs"."updated_at" IS 'Date de dernière modification de l''utilisateur'; + +COMMENT ON COLUMN "public"."utilisateurs"."region_id" IS 'Identifiant de la région administrée par l''utilisateur'; + +ALTER TABLE ONLY "public"."audit_logs" ALTER COLUMN "id" SET DEFAULT "nextval"('"public"."audit_logs_id_seq"'::"regclass"); + +ALTER TABLE ONLY "public"."horaires_gares_desservies" ALTER COLUMN "id" SET DEFAULT "nextval"('"public"."horaires_gares_desservies_id_seq"'::"regclass"); + +ALTER TABLE ONLY "public"."tarifs" ALTER COLUMN "id" SET DEFAULT "nextval"('"public"."tarifs_id_seq"'::"regclass"); + +ALTER TABLE ONLY "public"."types_train" ALTER COLUMN "id" SET DEFAULT "nextval"('"public"."types_train_id_seq"'::"regclass"); + +ALTER TABLE ONLY "public"."actualites" + ADD CONSTRAINT "actualites_pkey" PRIMARY KEY ("id"); + +ALTER TABLE ONLY "public"."audit_logs" + ADD CONSTRAINT "audit_logs_pkey" PRIMARY KEY ("id"); + +ALTER TABLE ONLY "public"."evenements" + ADD CONSTRAINT "evenements_pkey" PRIMARY KEY ("id"); + +ALTER TABLE ONLY "public"."gare_quais" + ADD CONSTRAINT "gare_quais_pkey" PRIMARY KEY ("id"); + +ALTER TABLE ONLY "public"."gare_transports" + ADD CONSTRAINT "gare_transports_pkey" PRIMARY KEY ("id"); + +ALTER TABLE ONLY "public"."gares" + ADD CONSTRAINT "gares_pkey" PRIMARY KEY ("id"); + +ALTER TABLE ONLY "public"."horaires_gares_desservies" + ADD CONSTRAINT "horaires_gares_desservies_pkey" PRIMARY KEY ("id"); + +ALTER TABLE ONLY "public"."horaires" + ADD CONSTRAINT "horaires_pkey" PRIMARY KEY ("id"); + +ALTER TABLE ONLY "public"."info_trafic" + ADD CONSTRAINT "info_trafic_pkey" PRIMARY KEY ("id"); + +ALTER TABLE ONLY "public"."lignes_gares" + ADD CONSTRAINT "lignes_gares_pkey" PRIMARY KEY ("id"); + +ALTER TABLE ONLY "public"."lignes" + ADD CONSTRAINT "lignes_pkey" PRIMARY KEY ("id"); + +ALTER TABLE ONLY "public"."materiel-roulant" + ADD CONSTRAINT "materiel-roulant_numero_serie_key" UNIQUE ("numero_serie"); + +ALTER TABLE ONLY "public"."materiel-roulant" + ADD CONSTRAINT "materiel-roulant_pkey" PRIMARY KEY ("id"); + +ALTER TABLE ONLY "public"."menus_regionaux" + ADD CONSTRAINT "menus_regionaux_pkey" PRIMARY KEY ("id"); + +ALTER TABLE ONLY "public"."messagerie_messages" + ADD CONSTRAINT "messagerie_messages_pkey" PRIMARY KEY ("id"); + +ALTER TABLE ONLY "public"."messagerie_participants" + ADD CONSTRAINT "messagerie_participants_pkey" PRIMARY KEY ("id"); + +ALTER TABLE ONLY "public"."messagerie_participants" + ADD CONSTRAINT "messagerie_participants_thread_id_user_id_key" UNIQUE ("thread_id", "user_id"); + +ALTER TABLE ONLY "public"."messagerie_threads" + ADD CONSTRAINT "messagerie_threads_pkey" PRIMARY KEY ("id"); + +ALTER TABLE ONLY "public"."page_personnalisation" + ADD CONSTRAINT "page_personnalisation_page_key" UNIQUE ("page"); + +ALTER TABLE ONLY "public"."page_personnalisation" + ADD CONSTRAINT "page_personnalisation_pkey" PRIMARY KEY ("id"); + +ALTER TABLE ONLY "public"."parametres" + ADD CONSTRAINT "parametres_cle_key" UNIQUE ("cle"); + +ALTER TABLE ONLY "public"."parametres" + ADD CONSTRAINT "parametres_pkey" PRIMARY KEY ("id"); + +ALTER TABLE ONLY "public"."perturbations_modifications_parcours" + ADD CONSTRAINT "perturbations_modifications_parcours_pkey" PRIMARY KEY ("id"); + +ALTER TABLE ONLY "public"."perturbations" + ADD CONSTRAINT "perturbations_pkey" PRIMARY KEY ("id"); + +ALTER TABLE ONLY "public"."profils_utilisateurs" + ADD CONSTRAINT "profils_utilisateurs_pkey" PRIMARY KEY ("id"); + +ALTER TABLE ONLY "public"."services_annuels" + ADD CONSTRAINT "services_annuels_pkey" PRIMARY KEY ("id"); + +ALTER TABLE ONLY "public"."tarifs" + ADD CONSTRAINT "tarifs_pkey" PRIMARY KEY ("id"); + +ALTER TABLE ONLY "public"."travaux" + ADD CONSTRAINT "travaux_pkey" PRIMARY KEY ("id"); + +ALTER TABLE ONLY "public"."travaux_substitutions" + ADD CONSTRAINT "travaux_substitutions_pkey" PRIMARY KEY ("id"); + +ALTER TABLE ONLY "public"."travaux_substitutions" + ADD CONSTRAINT "travaux_substitutions_travaux_id_horaire_id_key" UNIQUE ("travaux_id", "horaire_id"); + +ALTER TABLE ONLY "public"."types_train" + ADD CONSTRAINT "types_train_pkey" PRIMARY KEY ("id"); + +ALTER TABLE ONLY "public"."utilisateurs" + ADD CONSTRAINT "utilisateurs_email_key" UNIQUE ("email"); + +ALTER TABLE ONLY "public"."utilisateurs" + ADD CONSTRAINT "utilisateurs_pkey" PRIMARY KEY ("id"); + +CREATE INDEX "idx_actualites_actif" ON "public"."actualites" USING "btree" ("actif") WHERE ("deleted_at" IS NULL); + +CREATE INDEX "idx_actualites_created_by" ON "public"."actualites" USING "btree" ("created_by"); + +CREATE INDEX "idx_actualites_publication" ON "public"."actualites" USING "btree" ("date_publication"); + +CREATE INDEX "idx_actualites_region_id" ON "public"."actualites" USING "btree" ("region_id"); + +CREATE INDEX "idx_evenements_actif" ON "public"."evenements" USING "btree" ("actif") WHERE ("deleted_at" IS NULL); + +CREATE INDEX "idx_evenements_created_by" ON "public"."evenements" USING "btree" ("created_by"); + +CREATE INDEX "idx_evenements_dates" ON "public"."evenements" USING "btree" ("date_debut", "date_fin"); + +CREATE INDEX "idx_evenements_region_id" ON "public"."evenements" USING "btree" ("region_id"); + +CREATE INDEX "idx_gare_quais_gare_id" ON "public"."gare_quais" USING "btree" ("gare_id"); + +CREATE INDEX "idx_gare_transports_gare_id" ON "public"."gare_transports" USING "btree" ("gare_id"); + +CREATE INDEX "idx_gares_desservies_horaire" ON "public"."horaires_gares_desservies" USING "btree" ("horaire_id"); + +CREATE INDEX "idx_gares_desservies_ordre" ON "public"."horaires_gares_desservies" USING "btree" ("horaire_id", "ordre"); + +CREATE INDEX "idx_hgd_gare_id" ON "public"."horaires_gares_desservies" USING "btree" ("gare_id"); + +CREATE INDEX "idx_hgd_horaire_id" ON "public"."horaires_gares_desservies" USING "btree" ("horaire_id"); + +CREATE INDEX "idx_horaires_actif" ON "public"."horaires" USING "btree" ("actif") WHERE ("deleted_at" IS NULL); + +CREATE INDEX "idx_horaires_gare_arrivee" ON "public"."horaires" USING "btree" ("gare_arrivee_id"); + +CREATE INDEX "idx_horaires_gare_depart" ON "public"."horaires" USING "btree" ("gare_depart_id"); + +CREATE INDEX "idx_horaires_ligne" ON "public"."horaires" USING "btree" ("ligne_id"); + +CREATE INDEX "idx_horaires_materiel" ON "public"."horaires" USING "btree" ("materiel_roulant_id"); + +CREATE INDEX "idx_horaires_numero_train" ON "public"."horaires" USING "btree" ("numero_train"); + +CREATE INDEX "idx_horaires_region_id" ON "public"."horaires" USING "btree" ("region_id"); + +CREATE INDEX "idx_horaires_service_annuel" ON "public"."horaires" USING "btree" ("service_annuel_id"); + +CREATE INDEX "idx_horaires_substitution" ON "public"."horaires" USING "btree" ("est_substitution"); + +CREATE INDEX "idx_horaires_type_train" ON "public"."horaires" USING "btree" ("type_train_id"); + +CREATE INDEX "idx_info_trafic_actif" ON "public"."info_trafic" USING "btree" ("actif") WHERE ("deleted_at" IS NULL); + +CREATE INDEX "idx_info_trafic_created_by" ON "public"."info_trafic" USING "btree" ("created_by"); + +CREATE INDEX "idx_info_trafic_dates" ON "public"."info_trafic" USING "btree" ("date_debut", "date_fin"); + +CREATE INDEX "idx_info_trafic_region_id" ON "public"."info_trafic" USING "btree" ("region_id"); + +CREATE INDEX "idx_lignes_actif" ON "public"."lignes" USING "btree" ("actif") WHERE ("deleted_at" IS NULL); + +CREATE INDEX "idx_lignes_fh_url" ON "public"."lignes" USING "btree" ("fh_url"); + +CREATE INDEX "idx_lignes_gares_ligne" ON "public"."lignes_gares" USING "btree" ("ligne_id"); + +CREATE INDEX "idx_lignes_gares_ordre" ON "public"."lignes_gares" USING "btree" ("ligne_id", "ordre"); + +CREATE INDEX "idx_menus_regionaux_region_id" ON "public"."menus_regionaux" USING "btree" ("region_id"); + +CREATE INDEX "idx_messagerie_messages_created_at" ON "public"."messagerie_messages" USING "btree" ("created_at" DESC); + +CREATE INDEX "idx_messagerie_messages_thread_id" ON "public"."messagerie_messages" USING "btree" ("thread_id"); + +CREATE INDEX "idx_messagerie_participants_user_id" ON "public"."messagerie_participants" USING "btree" ("user_id"); + +CREATE INDEX "idx_modif_parcours_gare" ON "public"."perturbations_modifications_parcours" USING "btree" ("gare_id"); + +CREATE INDEX "idx_modif_parcours_perturbation" ON "public"."perturbations_modifications_parcours" USING "btree" ("perturbation_id"); + +CREATE INDEX "idx_perturbations_actif" ON "public"."perturbations" USING "btree" ("actif") WHERE ("deleted_at" IS NULL); + +CREATE INDEX "idx_perturbations_created_by" ON "public"."perturbations" USING "btree" ("created_by"); + +CREATE INDEX "idx_perturbations_dates" ON "public"."perturbations" USING "btree" ("date_debut", "date_fin"); + +CREATE INDEX "idx_perturbations_horaire" ON "public"."perturbations" USING "btree" ("horaire_id"); + +CREATE INDEX "idx_perturbations_region_id" ON "public"."perturbations" USING "btree" ("region_id"); + +CREATE INDEX "idx_perturbations_sillon_substitution" ON "public"."perturbations" USING "btree" ("sillon_substitution_id"); + +CREATE INDEX "idx_perturbations_type" ON "public"."perturbations" USING "btree" ("type_perturbation"); + +CREATE INDEX "idx_profils_email" ON "public"."profils_utilisateurs" USING "btree" ("email"); + +CREATE INDEX "idx_services_annuels_actif" ON "public"."services_annuels" USING "btree" ("actif") WHERE ("deleted_at" IS NULL); + +CREATE INDEX "idx_services_annuels_dates" ON "public"."services_annuels" USING "btree" ("date_debut", "date_fin"); + +CREATE INDEX "idx_tarifs_ordre" ON "public"."tarifs" USING "btree" ("ordre"); + +CREATE INDEX "idx_tarifs_regions" ON "public"."tarifs" USING "gin" ("regions"); + +CREATE INDEX "idx_travaux_actif" ON "public"."travaux" USING "btree" ("actif"); + +CREATE INDEX "idx_travaux_created_by" ON "public"."travaux" USING "btree" ("created_by"); + +CREATE INDEX "idx_travaux_dates" ON "public"."travaux" USING "btree" ("date_debut", "date_fin"); + +CREATE INDEX "idx_travaux_deleted" ON "public"."travaux" USING "btree" ("deleted_at"); + +CREATE INDEX "idx_travaux_ligne_id" ON "public"."travaux" USING "btree" ("ligne_id"); + +CREATE INDEX "idx_travaux_region_id" ON "public"."travaux" USING "btree" ("region_id"); + +CREATE INDEX "idx_travaux_substitutions_attribuees_gin" ON "public"."travaux" USING "gin" ("substitutions_attribuees"); + +CREATE INDEX "idx_travaux_substitutions_horaire" ON "public"."travaux_substitutions" USING "btree" ("horaire_id"); + +CREATE INDEX "idx_travaux_substitutions_travaux" ON "public"."travaux_substitutions" USING "btree" ("travaux_id"); + +CREATE INDEX "idx_types_train_code" ON "public"."types_train" USING "btree" ("code"); + +CREATE INDEX "idx_types_train_deleted_at" ON "public"."types_train" USING "btree" ("deleted_at"); + +CREATE INDEX "idx_utilisateurs_actif" ON "public"."utilisateurs" USING "btree" ("actif"); + +CREATE INDEX "idx_utilisateurs_email" ON "public"."utilisateurs" USING "btree" ("email"); + +CREATE INDEX "idx_utilisateurs_region_id" ON "public"."utilisateurs" USING "btree" ("region_id"); + +CREATE INDEX "idx_utilisateurs_role" ON "public"."utilisateurs" USING "btree" ("role"); + +CREATE UNIQUE INDEX "utilisateurs_email_lower_idx" ON "public"."utilisateurs" USING "btree" ("lower"(("email")::"text")); + +CREATE OR REPLACE TRIGGER "set_profils_updated_at" BEFORE UPDATE ON "public"."profils_utilisateurs" FOR EACH ROW EXECUTE FUNCTION "public"."handle_updated_at"(); + +CREATE OR REPLACE TRIGGER "tr_gares_audit" AFTER INSERT OR DELETE OR UPDATE ON "public"."gares" FOR EACH ROW EXECUTE FUNCTION "public"."log_audit"(); + +CREATE OR REPLACE TRIGGER "tr_gares_set_updated_at" BEFORE UPDATE ON "public"."gares" FOR EACH ROW EXECUTE FUNCTION "public"."set_updated_at"(); + +CREATE OR REPLACE TRIGGER "tr_utilisateurs_audit" AFTER INSERT OR DELETE OR UPDATE ON "public"."utilisateurs" FOR EACH ROW EXECUTE FUNCTION "public"."log_audit"(); + +CREATE OR REPLACE TRIGGER "tr_utilisateurs_set_updated_at" BEFORE UPDATE ON "public"."utilisateurs" FOR EACH ROW EXECUTE FUNCTION "public"."set_updated_at"(); + +CREATE OR REPLACE TRIGGER "trigger_update_travaux_updated_at" BEFORE UPDATE ON "public"."travaux" FOR EACH ROW EXECUTE FUNCTION "public"."update_travaux_updated_at"(); + +CREATE OR REPLACE TRIGGER "trigger_update_utilisateurs_updated_at" BEFORE UPDATE ON "public"."utilisateurs" FOR EACH ROW EXECUTE FUNCTION "public"."update_utilisateurs_updated_at"(); + +CREATE OR REPLACE TRIGGER "update_actualites_updated_at" BEFORE UPDATE ON "public"."actualites" FOR EACH ROW EXECUTE FUNCTION "public"."update_updated_at_column"(); + +CREATE OR REPLACE TRIGGER "update_evenements_updated_at" BEFORE UPDATE ON "public"."evenements" FOR EACH ROW EXECUTE FUNCTION "public"."update_updated_at_column"(); + +CREATE OR REPLACE TRIGGER "update_horaires_gares_desservies_updated_at" BEFORE UPDATE ON "public"."horaires_gares_desservies" FOR EACH ROW EXECUTE FUNCTION "public"."update_updated_at_column"(); + +CREATE OR REPLACE TRIGGER "update_horaires_updated_at" BEFORE UPDATE ON "public"."horaires" FOR EACH ROW EXECUTE FUNCTION "public"."update_updated_at_column"(); + +CREATE OR REPLACE TRIGGER "update_info_trafic_updated_at" BEFORE UPDATE ON "public"."info_trafic" FOR EACH ROW EXECUTE FUNCTION "public"."update_updated_at_column"(); + +CREATE OR REPLACE TRIGGER "update_lignes_updated_at" BEFORE UPDATE ON "public"."lignes" FOR EACH ROW EXECUTE FUNCTION "public"."update_updated_at_column"(); + +CREATE OR REPLACE TRIGGER "update_perturbations_updated_at" BEFORE UPDATE ON "public"."perturbations" FOR EACH ROW EXECUTE FUNCTION "public"."update_updated_at_column"(); + +CREATE OR REPLACE TRIGGER "update_services_annuels_updated_at" BEFORE UPDATE ON "public"."services_annuels" FOR EACH ROW EXECUTE FUNCTION "public"."update_updated_at_column"(); + +ALTER TABLE ONLY "public"."evenements" + ADD CONSTRAINT "evenements_gare_id_fkey" FOREIGN KEY ("gare_id") REFERENCES "public"."gares"("id") ON DELETE SET NULL; + +ALTER TABLE ONLY "public"."horaires_gares_desservies" + ADD CONSTRAINT "fk_horaires_gares_horaire_id" FOREIGN KEY ("horaire_id") REFERENCES "public"."horaires"("id") ON DELETE CASCADE; + +ALTER TABLE ONLY "public"."gare_quais" + ADD CONSTRAINT "gare_quais_gare_id_fkey" FOREIGN KEY ("gare_id") REFERENCES "public"."gares"("id") ON DELETE CASCADE; + +ALTER TABLE ONLY "public"."gare_transports" + ADD CONSTRAINT "gare_transports_gare_id_fkey" FOREIGN KEY ("gare_id") REFERENCES "public"."gares"("id") ON DELETE CASCADE; + +ALTER TABLE ONLY "public"."horaires" + ADD CONSTRAINT "horaires_gare_arrivee_id_fkey" FOREIGN KEY ("gare_arrivee_id") REFERENCES "public"."gares"("id") ON DELETE SET NULL; + +ALTER TABLE ONLY "public"."horaires" + ADD CONSTRAINT "horaires_gare_depart_id_fkey" FOREIGN KEY ("gare_depart_id") REFERENCES "public"."gares"("id") ON DELETE SET NULL; + +ALTER TABLE ONLY "public"."horaires_gares_desservies" + ADD CONSTRAINT "horaires_gares_desservies_gare_id_fkey" FOREIGN KEY ("gare_id") REFERENCES "public"."gares"("id") ON DELETE SET NULL; + +ALTER TABLE ONLY "public"."horaires_gares_desservies" + ADD CONSTRAINT "horaires_gares_desservies_quai_id_fkey" FOREIGN KEY ("quai_id") REFERENCES "public"."gare_quais"("id"); + +ALTER TABLE ONLY "public"."horaires" + ADD CONSTRAINT "horaires_ligne_id_fkey" FOREIGN KEY ("ligne_id") REFERENCES "public"."lignes"("id") ON DELETE SET NULL; + +ALTER TABLE ONLY "public"."horaires" + ADD CONSTRAINT "horaires_materiel_roulant_id_fkey" FOREIGN KEY ("materiel_roulant_id") REFERENCES "public"."materiel-roulant"("id") ON DELETE SET NULL; + +ALTER TABLE ONLY "public"."horaires" + ADD CONSTRAINT "horaires_service_annuel_id_fkey" FOREIGN KEY ("service_annuel_id") REFERENCES "public"."services_annuels"("id") ON DELETE SET NULL; + +ALTER TABLE ONLY "public"."horaires" + ADD CONSTRAINT "horaires_type_train_id_fkey" FOREIGN KEY ("type_train_id") REFERENCES "public"."types_train"("id") ON DELETE SET NULL; + +ALTER TABLE ONLY "public"."info_trafic" + ADD CONSTRAINT "info_trafic_gare_id_fkey" FOREIGN KEY ("gare_id") REFERENCES "public"."gares"("id") ON DELETE SET NULL; + +ALTER TABLE ONLY "public"."lignes" + ADD CONSTRAINT "lignes_gare_arrivee_id_fkey" FOREIGN KEY ("gare_arrivee_id") REFERENCES "public"."gares"("id") ON DELETE SET NULL; + +ALTER TABLE ONLY "public"."lignes" + ADD CONSTRAINT "lignes_gare_depart_id_fkey" FOREIGN KEY ("gare_depart_id") REFERENCES "public"."gares"("id") ON DELETE SET NULL; + +ALTER TABLE ONLY "public"."lignes_gares" + ADD CONSTRAINT "lignes_gares_gare_id_fkey" FOREIGN KEY ("gare_id") REFERENCES "public"."gares"("id") ON DELETE CASCADE; + +ALTER TABLE ONLY "public"."lignes_gares" + ADD CONSTRAINT "lignes_gares_ligne_id_fkey" FOREIGN KEY ("ligne_id") REFERENCES "public"."lignes"("id") ON DELETE CASCADE; + +ALTER TABLE ONLY "public"."messagerie_messages" + ADD CONSTRAINT "messagerie_messages_sender_id_fkey" FOREIGN KEY ("sender_id") REFERENCES "public"."utilisateurs"("id") ON DELETE CASCADE; + +ALTER TABLE ONLY "public"."messagerie_messages" + ADD CONSTRAINT "messagerie_messages_thread_id_fkey" FOREIGN KEY ("thread_id") REFERENCES "public"."messagerie_threads"("id") ON DELETE CASCADE; + +ALTER TABLE ONLY "public"."messagerie_participants" + ADD CONSTRAINT "messagerie_participants_thread_id_fkey" FOREIGN KEY ("thread_id") REFERENCES "public"."messagerie_threads"("id") ON DELETE CASCADE; + +ALTER TABLE ONLY "public"."messagerie_participants" + ADD CONSTRAINT "messagerie_participants_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "public"."utilisateurs"("id") ON DELETE CASCADE; + +ALTER TABLE ONLY "public"."perturbations" + ADD CONSTRAINT "perturbations_horaire_id_fkey" FOREIGN KEY ("horaire_id") REFERENCES "public"."horaires"("id") ON DELETE CASCADE; + +ALTER TABLE ONLY "public"."perturbations_modifications_parcours" + ADD CONSTRAINT "perturbations_modifications_parcours_gare_id_fkey" FOREIGN KEY ("gare_id") REFERENCES "public"."gares"("id") ON DELETE SET NULL; + +ALTER TABLE ONLY "public"."perturbations_modifications_parcours" + ADD CONSTRAINT "perturbations_modifications_parcours_perturbation_id_fkey" FOREIGN KEY ("perturbation_id") REFERENCES "public"."perturbations"("id") ON DELETE CASCADE; + +ALTER TABLE ONLY "public"."perturbations" + ADD CONSTRAINT "perturbations_sillon_substitution_id_fkey" FOREIGN KEY ("sillon_substitution_id") REFERENCES "public"."horaires"("id") ON DELETE SET NULL; + +ALTER TABLE ONLY "public"."profils_utilisateurs" + ADD CONSTRAINT "profils_utilisateurs_id_fkey" FOREIGN KEY ("id") REFERENCES "auth"."users"("id") ON DELETE CASCADE; + +ALTER TABLE ONLY "public"."travaux" + ADD CONSTRAINT "travaux_created_by_fkey" FOREIGN KEY ("created_by") REFERENCES "public"."utilisateurs"("id"); + +ALTER TABLE ONLY "public"."travaux" + ADD CONSTRAINT "travaux_ligne_id_fkey" FOREIGN KEY ("ligne_id") REFERENCES "public"."lignes"("id") ON DELETE SET NULL; + +ALTER TABLE ONLY "public"."travaux_substitutions" + ADD CONSTRAINT "travaux_substitutions_horaire_id_fkey" FOREIGN KEY ("horaire_id") REFERENCES "public"."horaires"("id") ON DELETE CASCADE; + +ALTER TABLE ONLY "public"."travaux_substitutions" + ADD CONSTRAINT "travaux_substitutions_travaux_id_fkey" FOREIGN KEY ("travaux_id") REFERENCES "public"."travaux"("id") ON DELETE CASCADE; + +CREATE POLICY "Insertion profil propre" ON "public"."profils_utilisateurs" FOR INSERT WITH CHECK (("auth"."uid"() = "id")); + +CREATE POLICY "Lecture profil propre" ON "public"."profils_utilisateurs" FOR SELECT USING (("auth"."uid"() = "id")); + +CREATE POLICY "Mise à jour profil propre" ON "public"."profils_utilisateurs" FOR UPDATE USING (("auth"."uid"() = "id")) WITH CHECK (("auth"."uid"() = "id")); + +CREATE POLICY "Suppression profil propre" ON "public"."profils_utilisateurs" FOR DELETE USING (("auth"."uid"() = "id")); + +CREATE POLICY "menus_delete" ON "public"."menus_regionaux" FOR DELETE USING (true); + +CREATE POLICY "menus_insert" ON "public"."menus_regionaux" FOR INSERT WITH CHECK (true); + +ALTER TABLE "public"."menus_regionaux" ENABLE ROW LEVEL SECURITY; + +CREATE POLICY "menus_select" ON "public"."menus_regionaux" FOR SELECT USING (true); + +CREATE POLICY "menus_update" ON "public"."menus_regionaux" FOR UPDATE USING (true); + +ALTER TABLE "public"."messagerie_messages" ENABLE ROW LEVEL SECURITY; + +CREATE POLICY "messagerie_messages_insert" ON "public"."messagerie_messages" FOR INSERT TO "anon" WITH CHECK ((EXISTS ( SELECT 1 + FROM "public"."messagerie_participants" "p" + WHERE (("p"."thread_id" = "messagerie_messages"."thread_id") AND ("p"."user_id" = "messagerie_messages"."sender_id"))))); + +CREATE POLICY "messagerie_messages_select" ON "public"."messagerie_messages" FOR SELECT TO "anon" USING ((EXISTS ( SELECT 1 + FROM "public"."messagerie_participants" "p" + WHERE ("p"."thread_id" = "messagerie_messages"."thread_id")))); + +ALTER TABLE "public"."messagerie_participants" ENABLE ROW LEVEL SECURITY; + +CREATE POLICY "messagerie_participants_insert" ON "public"."messagerie_participants" FOR INSERT TO "anon" WITH CHECK (true); + +CREATE POLICY "messagerie_participants_select" ON "public"."messagerie_participants" FOR SELECT TO "anon" USING (true); + +ALTER TABLE "public"."messagerie_threads" ENABLE ROW LEVEL SECURITY; + +CREATE POLICY "messagerie_threads_insert" ON "public"."messagerie_threads" FOR INSERT TO "anon" WITH CHECK (true); + +CREATE POLICY "messagerie_threads_select" ON "public"."messagerie_threads" FOR SELECT TO "anon" USING ((EXISTS ( SELECT 1 + FROM "public"."messagerie_participants" "p" + WHERE ("p"."thread_id" = "messagerie_threads"."id")))); + +CREATE POLICY "messagerie_threads_update" ON "public"."messagerie_threads" FOR UPDATE TO "anon" USING ((EXISTS ( SELECT 1 + FROM "public"."messagerie_participants" "p" + WHERE ("p"."thread_id" = "messagerie_threads"."id")))) WITH CHECK (true); + +ALTER TABLE "public"."parametres" ENABLE ROW LEVEL SECURITY; + +CREATE POLICY "parametres_delete_all" ON "public"."parametres" FOR DELETE USING (true); + +CREATE POLICY "parametres_insert_all" ON "public"."parametres" FOR INSERT WITH CHECK (true); + +CREATE POLICY "parametres_select_all" ON "public"."parametres" FOR SELECT USING (true); + +CREATE POLICY "parametres_update_all" ON "public"."parametres" FOR UPDATE USING (true); + +ALTER TABLE "public"."profils_utilisateurs" ENABLE ROW LEVEL SECURITY; + +ALTER DEFAULT PRIVILEGES FOR ROLE "postgres" IN SCHEMA "public" GRANT ALL ON SEQUENCES TO "postgres"; +ALTER DEFAULT PRIVILEGES FOR ROLE "postgres" IN SCHEMA "public" GRANT ALL ON SEQUENCES TO "anon"; +ALTER DEFAULT PRIVILEGES FOR ROLE "postgres" IN SCHEMA "public" GRANT ALL ON SEQUENCES TO "authenticated"; +ALTER DEFAULT PRIVILEGES FOR ROLE "postgres" IN SCHEMA "public" GRANT ALL ON SEQUENCES TO "service_role"; + +ALTER DEFAULT PRIVILEGES FOR ROLE "postgres" IN SCHEMA "public" GRANT ALL ON FUNCTIONS TO "postgres"; +ALTER DEFAULT PRIVILEGES FOR ROLE "postgres" IN SCHEMA "public" GRANT ALL ON FUNCTIONS TO "anon"; +ALTER DEFAULT PRIVILEGES FOR ROLE "postgres" IN SCHEMA "public" GRANT ALL ON FUNCTIONS TO "authenticated"; +ALTER DEFAULT PRIVILEGES FOR ROLE "postgres" IN SCHEMA "public" GRANT ALL ON FUNCTIONS TO "service_role"; + +ALTER DEFAULT PRIVILEGES FOR ROLE "postgres" IN SCHEMA "public" GRANT ALL ON TABLES TO "postgres"; +ALTER DEFAULT PRIVILEGES FOR ROLE "postgres" IN SCHEMA "public" GRANT ALL ON TABLES TO "anon"; +ALTER DEFAULT PRIVILEGES FOR ROLE "postgres" IN SCHEMA "public" GRANT ALL ON TABLES TO "authenticated"; +ALTER DEFAULT PRIVILEGES FOR ROLE "postgres" IN SCHEMA "public" GRANT ALL ON TABLES TO "service_role"; + diff --git a/schema_sql_complet.sql b/schema_sql_complet.sql new file mode 100644 index 0000000..18049f2 --- /dev/null +++ b/schema_sql_complet.sql @@ -0,0 +1,2763 @@ +CREATE TYPE public.service_type AS ENUM ('TER', 'TGV', 'Intercités', 'Fret'); +CREATE TYPE public.station_type AS ENUM ('interurbaine', 'ville'); +CREATE TYPE public.transport_type AS ENUM ('bus', 'tram', 'metro', 'tram-train', 'train'); +-- Script SQL pour Supabase — Base app_ferrovia (exécuter dans SQL editor Supabase) + +-- Extensions requises +CREATE EXTENSION IF NOT EXISTS "pgcrypto"; +CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; + +-- Table des utilisateurs +CREATE TABLE IF NOT EXISTS public.utilisateurs ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + email text NOT NULL UNIQUE, + nom text, + prenom text, + password_hash text NOT NULL, + role text NOT NULL DEFAULT 'user', + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz +); + +CREATE UNIQUE INDEX IF NOT EXISTS utilisateurs_email_lower_idx ON public.utilisateurs ((lower(email))); + +-- Table d'audit +CREATE TABLE IF NOT EXISTS public.audit_logs ( + id bigserial PRIMARY KEY, + table_name text NOT NULL, + operation text NOT NULL, + record_id uuid, + changed_by uuid, + old_data jsonb, + new_data jsonb, + changed_at timestamptz NOT NULL DEFAULT now() +); + +-- Trigger pour updated_at +CREATE OR REPLACE FUNCTION public.set_updated_at() +RETURNS trigger LANGUAGE plpgsql AS $$ +BEGIN + NEW.updated_at = now(); + RETURN NEW; +END; +$$; + +DROP TRIGGER IF EXISTS tr_utilisateurs_set_updated_at ON public.utilisateurs; +CREATE TRIGGER tr_utilisateurs_set_updated_at +BEFORE UPDATE ON public.utilisateurs +FOR EACH ROW EXECUTE FUNCTION public.set_updated_at(); + +-- Fonction d'audit +CREATE OR REPLACE FUNCTION public.log_audit() +RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER AS $$ +DECLARE + v_changed_by_text text; + v_changed_by uuid; +BEGIN + v_changed_by_text := current_setting('jwt.claims.user_id', true); + IF v_changed_by_text IS NOT NULL AND v_changed_by_text <> '' THEN + BEGIN + v_changed_by := v_changed_by_text::uuid; + EXCEPTION WHEN others THEN + v_changed_by := NULL; + END; + ELSE + v_changed_by := NULL; + END IF; + + IF (TG_OP = 'INSERT') THEN + INSERT INTO public.audit_logs(table_name, operation, record_id, changed_by, new_data, changed_at) + VALUES (TG_TABLE_NAME, 'INSERT', NEW.id, v_changed_by, row_to_json(NEW)::jsonb, now()); + RETURN NEW; + ELSIF (TG_OP = 'UPDATE') THEN + INSERT INTO public.audit_logs(table_name, operation, record_id, changed_by, old_data, new_data, changed_at) + VALUES (TG_TABLE_NAME, 'UPDATE', NEW.id, v_changed_by, row_to_json(OLD)::jsonb, row_to_json(NEW)::jsonb, now()); + RETURN NEW; + ELSIF (TG_OP = 'DELETE') THEN + INSERT INTO public.audit_logs(table_name, operation, record_id, changed_by, old_data, changed_at) + VALUES (TG_TABLE_NAME, 'DELETE', OLD.id, v_changed_by, row_to_json(OLD)::jsonb, now()); + RETURN OLD; + END IF; + RETURN NULL; +END; +$$; + +DROP TRIGGER IF EXISTS tr_utilisateurs_audit ON public.utilisateurs; +CREATE TRIGGER tr_utilisateurs_audit +AFTER INSERT OR UPDATE OR DELETE ON public.utilisateurs +FOR EACH ROW EXECUTE FUNCTION public.log_audit(); + +-- Fonction RPC d'authentification +CREATE OR REPLACE FUNCTION public.authenticate_user(p_email text, p_password text) +RETURNS TABLE(id uuid, email text, nom text, prenom text, name text, role text) LANGUAGE plpgsql STABLE SECURITY DEFINER AS $$ +DECLARE + has_nom boolean; + has_prenom boolean; + has_fullname boolean; + has_deleted_at boolean; + sql text; +BEGIN + SELECT EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema='public' AND table_name='utilisateurs' AND column_name='nom'), + EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema='public' AND table_name='utilisateurs' AND column_name='prenom'), + EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema='public' AND table_name='utilisateurs' AND column_name='full_name'), + EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema='public' AND table_name='utilisateurs' AND column_name='deleted_at') + INTO has_nom, has_prenom, has_fullname, has_deleted_at; + + IF has_nom AND has_prenom THEN + sql := 'SELECT u.id::uuid, u.email::text, u.nom::text, u.prenom::text, (COALESCE(u.nom,'''') || '' '' || COALESCE(u.prenom, ''''))::text as name, u.role::text ' + || 'FROM public.utilisateurs u ' + || 'WHERE lower(u.email)=lower($1) AND u.password_hash = crypt($2, u.password_hash)'; + ELSIF has_fullname THEN + sql := 'SELECT u.id::uuid, u.email::text, NULL::text as nom, NULL::text as prenom, u.full_name::text as name, u.role::text ' + || 'FROM public.utilisateurs u ' + || 'WHERE lower(u.email)=lower($1) AND u.password_hash = crypt($2, u.password_hash)'; + ELSE + RAISE EXCEPTION 'Table public.utilisateurs : colonnes nom/prenom ou full_name introuvables'; + END IF; + + IF has_deleted_at THEN + sql := sql || ' AND u.deleted_at IS NULL'; + END IF; + + RETURN QUERY EXECUTE sql USING p_email, p_password; +END; +$$; + +-- Grants (à adapter selon politique RLS) +GRANT EXECUTE ON FUNCTION public.authenticate_user(text, text) TO anon; +GRANT SELECT ON public.utilisateurs TO anon; + +-- Insérer un utilisateur de test (admin) +-- Utiliser nom/prenom pour compatibilité avec le schéma principal +INSERT INTO public.utilisateurs (email, nom, prenom, password_hash, role) +VALUES ( + 'admin@example.com', + 'Admin', + 'Example', + crypt('password', gen_salt('bf')), + 'admin' +) ON CONFLICT (email) DO UPDATE +SET password_hash = EXCLUDED.password_hash, + nom = EXCLUDED.nom, + prenom = EXCLUDED.prenom, + role = EXCLUDED.role, + updated_at = now(); + +-- ============================================= +-- TABLES EQUIVALENTES POUR 'GARES' (doublon pour compatibilité) +-- ============================================= + +-- Table principale des gares (nommage français) +CREATE TABLE IF NOT EXISTS public.gares ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + nom text NOT NULL, + type station_type NOT NULL DEFAULT 'ville', + services service_type[] NOT NULL DEFAULT '{}', + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz + ); + +-- Table des quais pour gares +CREATE TABLE IF NOT EXISTS public.gare_quais ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + gare_id uuid NOT NULL REFERENCES public.gares(id) ON DELETE CASCADE, + nom text NOT NULL, + distance_metres integer NOT NULL DEFAULT 0, + created_at timestamptz NOT NULL DEFAULT now() + ); + +-- Table des transports pour gares +CREATE TABLE IF NOT EXISTS public.gare_transports ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + gare_id uuid NOT NULL REFERENCES public.gares(id) ON DELETE CASCADE, + type transport_type NOT NULL, + ligne text, + created_at timestamptz NOT NULL DEFAULT now() + ); + +-- Index pour les recherches +CREATE INDEX IF NOT EXISTS idx_gare_quais_gare_id ON public.gare_quais(gare_id); +CREATE INDEX IF NOT EXISTS idx_gare_transports_gare_id ON public.gare_transports(gare_id); + +-- Trigger pour updated_at sur gares +DROP TRIGGER IF EXISTS tr_gares_set_updated_at ON public.gares; +CREATE TRIGGER tr_gares_set_updated_at + BEFORE UPDATE ON public.gares + FOR EACH ROW EXECUTE FUNCTION public.set_updated_at(); + +-- Trigger d'audit pour gares +DROP TRIGGER IF EXISTS tr_gares_audit ON public.gares; +CREATE TRIGGER tr_gares_audit + AFTER INSERT OR UPDATE OR DELETE ON public.gares + FOR EACH ROW EXECUTE FUNCTION public.log_audit(); + +-- Grants pour les tables gares +GRANT SELECT, INSERT, UPDATE, DELETE ON public.gares TO anon; +GRANT SELECT, INSERT, UPDATE, DELETE ON public.gare_quais TO anon; +GRANT SELECT, INSERT, UPDATE, DELETE ON public.gare_transports TO anon; + +-- Fonction RPC pour récupérer une gare (gare) avec ses quais et transports +CREATE OR REPLACE FUNCTION public.get_gare_details(p_gare_id uuid) +RETURNS TABLE( + id uuid, + nom text, + type station_type, + services service_type[], + quais jsonb, + transports jsonb, + created_at timestamptz, + updated_at timestamptz +) LANGUAGE plpgsql STABLE SECURITY DEFINER AS $$ +BEGIN +RETURN QUERY +SELECT + g.id, + g.nom, + g.type, + g.services, + COALESCE( + (SELECT jsonb_agg(jsonb_build_object('id', q.id, 'nom', q.nom, 'distance_metres', q.distance_metres)) + FROM public.gare_quais q WHERE q.gare_id = g.id), + '[]'::jsonb + ) as quais, + COALESCE( + (SELECT jsonb_agg(jsonb_build_object('id', t.id, 'type', t.type, 'ligne', t.ligne)) + FROM public.gare_transports t WHERE t.gare_id = g.id), + '[]'::jsonb + ) as transports, + g.created_at, + g.updated_at +FROM public.gares g +WHERE g.id = p_gare_id AND g.deleted_at IS NULL; +END; +$$; + +GRANT EXECUTE ON FUNCTION public.get_gare_details(uuid) TO anon; + + +-- ============================================ +-- TABLE DES MATERIELS ROULANTS +-- ============================================ +create table if not exists "materiel-roulant" ( + id bigint generated always as identity primary key, + nom text, + nom_technique text, + capacite integer, + image_url text, + type_train text, + numero_serie text unique, + created_at timestamptz default now(), + updated_at timestamptz + ); +-- ============================================================================ +-- TABLE : utilisateurs +-- Description : Gestion des utilisateurs de l'application +-- ============================================================================ + +CREATE TABLE IF NOT EXISTS utilisateurs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + email VARCHAR(255) NOT NULL UNIQUE, + nom VARCHAR(100) NOT NULL, + prenom VARCHAR(100) NOT NULL, + role VARCHAR(50) NOT NULL DEFAULT 'utilisateur', + actif BOOLEAN NOT NULL DEFAULT true, + -- Champs additionnels + password_hash VARCHAR(255), + photo_url TEXT, + region_admin BOOLEAN DEFAULT false, + region_nom VARCHAR(255), + permissions JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +-- Si la table existe déjà, ajouter les colonnes manquantes (migration sûre) +ALTER TABLE utilisateurs ADD COLUMN IF NOT EXISTS password_hash VARCHAR(255); +ALTER TABLE utilisateurs ADD COLUMN IF NOT EXISTS photo_url TEXT; +ALTER TABLE utilisateurs ADD COLUMN IF NOT EXISTS region_admin BOOLEAN DEFAULT false; +ALTER TABLE utilisateurs ADD COLUMN IF NOT EXISTS region_nom VARCHAR(255); +ALTER TABLE utilisateurs ADD COLUMN IF NOT EXISTS permissions JSONB NOT NULL DEFAULT '{}'::jsonb; +ALTER TABLE utilisateurs ADD COLUMN IF NOT EXISTS actif BOOLEAN NOT NULL DEFAULT true; + +-- Index sur l'email pour optimiser les recherches +CREATE INDEX IF NOT EXISTS idx_utilisateurs_email ON utilisateurs(email); + +-- Index sur le rôle +CREATE INDEX IF NOT EXISTS idx_utilisateurs_role ON utilisateurs(role); + +-- Index sur le statut actif +CREATE INDEX IF NOT EXISTS idx_utilisateurs_actif ON utilisateurs(actif); + +-- Commentaires +COMMENT ON TABLE utilisateurs IS 'Utilisateurs de l''application avec leurs informations et rôles'; +COMMENT ON COLUMN utilisateurs.id IS 'Identifiant unique de l''utilisateur'; +COMMENT ON COLUMN utilisateurs.email IS 'Adresse email de l''utilisateur (unique)'; +COMMENT ON COLUMN utilisateurs.nom IS 'Nom de famille de l''utilisateur'; +COMMENT ON COLUMN utilisateurs.prenom IS 'Prénom de l''utilisateur'; +COMMENT ON COLUMN utilisateurs.role IS 'Rôle de l''utilisateur (admin, gestionnaire, utilisateur)'; +COMMENT ON COLUMN utilisateurs.actif IS 'Indique si l''utilisateur est actif ou désactivé'; +COMMENT ON COLUMN utilisateurs.created_at IS 'Date de création de l''utilisateur'; +COMMENT ON COLUMN utilisateurs.updated_at IS 'Date de dernière modification de l''utilisateur'; +COMMENT ON COLUMN utilisateurs.password_hash IS 'Hash du mot de passe (ne pas stocker en clair)'; +COMMENT ON COLUMN utilisateurs.photo_url IS 'URL de la photo de profil'; +COMMENT ON COLUMN utilisateurs.region_admin IS 'Indique si l''utilisateur administre une région'; +COMMENT ON COLUMN utilisateurs.region_nom IS 'Nom de la région administrée (si applicable)'; +COMMENT ON COLUMN utilisateurs.permissions IS 'Permissions par fonctionnalite stockees en JSONB'; + +-- Fonction pour mettre à jour automatiquement updated_at +CREATE OR REPLACE FUNCTION update_utilisateurs_updated_at() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = NOW(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +-- Trigger pour updated_at +DROP TRIGGER IF EXISTS trigger_update_utilisateurs_updated_at ON utilisateurs; +CREATE TRIGGER trigger_update_utilisateurs_updated_at + BEFORE UPDATE ON utilisateurs + FOR EACH ROW + EXECUTE FUNCTION update_utilisateurs_updated_at(); + +-- ===================================================== +-- Fonction RPC d'authentification utilisant la table utilisateurs +-- Cette fonction vérifie le mot de passe via pgcrypto/crypt +-- Retourne id, email, nom, prenom, role si succès +-- ===================================================== + +-- S'assurer que pgcrypto est disponible +CREATE EXTENSION IF NOT EXISTS pgcrypto; + +CREATE OR REPLACE FUNCTION public.authenticate_user(p_email text, p_password text) +RETURNS TABLE(id uuid, email text, nom text, prenom text, name text, role text) LANGUAGE plpgsql STABLE SECURITY DEFINER AS $$ +DECLARE + has_deleted_at boolean; + sql text; +BEGIN + SELECT EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema='public' AND table_name='utilisateurs' AND column_name='deleted_at') + INTO has_deleted_at; + + sql := 'SELECT u.id::uuid, u.email::text, u.nom::text, u.prenom::text, (COALESCE(u.nom, '''') || '' '' || COALESCE(u.prenom, ''''))::text as name, u.role::text ' + || 'FROM public.utilisateurs u ' + || 'WHERE lower(u.email) = lower($1) ' + || 'AND u.password_hash IS NOT NULL ' + || 'AND u.password_hash = crypt($2, u.password_hash) ' + || 'AND coalesce(u.actif, true) = true'; + + IF has_deleted_at THEN + sql := sql || ' AND u.deleted_at IS NULL'; + END IF; + + RETURN QUERY EXECUTE sql USING p_email, p_password; +END; +$$; + +-- Grants pour permettre l'appel depuis le client (adapter selon politiques RLS) +GRANT EXECUTE ON FUNCTION public.authenticate_user(text, text) TO anon; +GRANT SELECT ON public.utilisateurs TO anon; + +-- Inserer un utilisateur admin de test (mot de passe: 'password') +INSERT INTO utilisateurs (email, nom, prenom, password_hash, role, actif) +VALUES ( + 'admin@example.com', + 'Admin', + 'Example', + crypt('password', gen_salt('bf')), + 'admin', + true +) +ON CONFLICT (email) DO UPDATE +SET password_hash = EXCLUDED.password_hash, + nom = EXCLUDED.nom, + prenom = EXCLUDED.prenom, + role = EXCLUDED.role, + actif = EXCLUDED.actif, + updated_at = NOW(); + +-- FIN du fichier +-- ============================================================================ +-- TABLE: types_train +-- Description: Stocke les types de train configurables (TER, TGV, etc.) +-- ============================================================================ + +CREATE TABLE IF NOT EXISTS types_train ( + id BIGSERIAL PRIMARY KEY, + nom VARCHAR(255) NOT NULL, + code VARCHAR(50), + description TEXT, + logo_url TEXT, + couleur VARCHAR(20) DEFAULT '#0074d9', + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + deleted_at TIMESTAMP WITH TIME ZONE +); + +-- Index +CREATE INDEX IF NOT EXISTS idx_types_train_deleted_at ON types_train(deleted_at); +CREATE INDEX IF NOT EXISTS idx_types_train_code ON types_train(code); + +-- ============================================================================ +-- MODIFICATION TABLE: horaires +-- Ajout de la colonne ligne et modification de type_train_id +-- ============================================================================ + +-- -- Ajouter colonne ligne si elle n'existe pas +-- DO $$ +-- BEGIN +-- IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name='horaires') AND NOT EXISTS ( +-- SELECT 1 FROM information_schema.columns +-- WHERE table_name='horaires' AND column_name='ligne' +-- ) THEN +-- ALTER TABLE horaires ADD COLUMN ligne VARCHAR(255); +-- END IF; +-- END $$; +-- +-- -- Ajouter colonne type_train_id si elle n'existe pas (relation vers types_train) +-- DO $$ +-- BEGIN +-- IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name='horaires') AND NOT EXISTS ( +-- SELECT 1 FROM information_schema.columns +-- WHERE table_name='horaires' AND column_name='type_train_id' +-- ) THEN +-- ALTER TABLE horaires ADD COLUMN type_train_id BIGINT REFERENCES types_train(id); +-- END IF; +-- END $$; +-- +-- -- Index +-- CREATE INDEX IF NOT EXISTS idx_horaires_ligne ON horaires(ligne); +-- CREATE INDEX IF NOT EXISTS idx_horaires_type_train_id ON horaires(type_train_id); + +-- ============================================================================ +-- DONNÉES INITIALES: types_train +-- Pré-remplir avec des types de train classiques +-- ============================================================================ + +INSERT INTO types_train (nom, code, description, couleur) VALUES + ('TER', 'TER', 'Transport Express Régional', '#0088ce'), + ('TGV', 'TGV', 'Train à Grande Vitesse', '#d2232a'), + ('Intercités', 'IC', 'Trains Intercités', '#00a88f'), + ('TGV INOUI', 'TGVI', 'TGV INOUI', '#d2232a'), + ('OUIGO', 'OUI', 'OUIGO', '#ed008c'), + ('Transilien', 'TRANS', 'Trains Transilien Île-de-France', '#8d5da8') +ON CONFLICT DO NOTHING; + +-- ============================================================================ +-- COMMENTAIRES +-- ============================================================================ + +COMMENT ON TABLE types_train IS 'Types de train configurables avec logos et couleurs'; +-- COMMENT ON COLUMN horaires.ligne IS 'Ligne de train (ex: Paris-Lyon, ligne N, etc.)'; +-- COMMENT ON COLUMN horaires.type_train_id IS 'Référence vers le type de train configuré'; +-- ============================================================================ +-- TABLES POUR LES LIGNES ET SERVICES ANNUELS +-- ============================================================================ + +-- Table des lignes +CREATE TABLE IF NOT EXISTS lignes ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + nom VARCHAR(255) NOT NULL, + code VARCHAR(50), + gare_depart_id UUID REFERENCES gares(id) ON DELETE SET NULL, + gare_arrivee_id UUID REFERENCES gares(id) ON DELETE SET NULL, + actif BOOLEAN DEFAULT true, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + deleted_at TIMESTAMP WITH TIME ZONE +); + +-- Table des gares sur le tracé d'une ligne +CREATE TABLE IF NOT EXISTS lignes_gares ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + ligne_id UUID REFERENCES lignes(id) ON DELETE CASCADE, + gare_id UUID REFERENCES gares(id) ON DELETE CASCADE, + ordre INTEGER NOT NULL, + desserte VARCHAR(20) NOT NULL CHECK (desserte IN ('tous', 'partielle')), + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +-- Table des services annuels +CREATE TABLE IF NOT EXISTS services_annuels ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + nom VARCHAR(255) NOT NULL, + code VARCHAR(50), + date_debut DATE NOT NULL, + date_fin DATE NOT NULL, + description TEXT, + actif BOOLEAN DEFAULT true, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + deleted_at TIMESTAMP WITH TIME ZONE +); +-- +-- -- Ajouter la colonne service_annuel_id à la table horaires si elle n'existe pas +-- DO $$ +-- BEGIN +-- IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name='horaires') AND NOT EXISTS ( +-- SELECT 1 FROM information_schema.columns +-- WHERE table_name = 'horaires' AND column_name = 'service_annuel_id' +-- ) THEN +-- ALTER TABLE horaires ADD COLUMN service_annuel_id UUID REFERENCES services_annuels(id) ON DELETE SET NULL; +-- END IF; +-- END $$; +-- +-- -- Ajouter la colonne ligne_id à la table horaires si elle n'existe pas +-- DO $$ +-- BEGIN +-- IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name='horaires') AND NOT EXISTS ( +-- SELECT 1 FROM information_schema.columns +-- WHERE table_name = 'horaires' AND column_name = 'ligne_id' +-- ) THEN +-- ALTER TABLE horaires ADD COLUMN ligne_id UUID REFERENCES lignes(id) ON DELETE SET NULL; +-- END IF; +-- END $$; + +-- Index pour améliorer les performances +CREATE INDEX IF NOT EXISTS idx_lignes_actif ON lignes(actif) WHERE deleted_at IS NULL; +CREATE INDEX IF NOT EXISTS idx_lignes_gares_ligne ON lignes_gares(ligne_id); +CREATE INDEX IF NOT EXISTS idx_lignes_gares_ordre ON lignes_gares(ligne_id, ordre); +CREATE INDEX IF NOT EXISTS idx_services_annuels_actif ON services_annuels(actif) WHERE deleted_at IS NULL; +CREATE INDEX IF NOT EXISTS idx_services_annuels_dates ON services_annuels(date_debut, date_fin); + +-- Trigger pour mettre à jour updated_at automatiquement +DROP TRIGGER IF EXISTS update_lignes_updated_at ON lignes; +CREATE TRIGGER update_lignes_updated_at + BEFORE UPDATE ON lignes + FOR EACH ROW + EXECUTE FUNCTION update_updated_at_column(); + +DROP TRIGGER IF EXISTS update_services_annuels_updated_at ON services_annuels; +CREATE TRIGGER update_services_annuels_updated_at + BEFORE UPDATE ON services_annuels + FOR EACH ROW + EXECUTE FUNCTION update_updated_at_column(); + +-- Commentaires sur les tables +COMMENT ON TABLE lignes IS 'Lignes de train avec gares de départ et d''arrivée'; +COMMENT ON TABLE lignes_gares IS 'Gares présentes sur le tracé d''une ligne avec type de desserte'; +COMMENT ON TABLE services_annuels IS 'Services annuels attribuables aux horaires'; +-- ============================================================================ +-- TABLES POUR LE SYSTÈME DE GESTION DES HORAIRES (SILLONS) +-- ============================================================================ + +-- Table principale des horaires (sillons) +CREATE TABLE IF NOT EXISTS horaires ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- Informations générales + numero_train VARCHAR(20) NOT NULL, + type_train VARCHAR(50), + type_train_id BIGINT REFERENCES types_train(id) ON DELETE SET NULL, + + -- Gare de départ + gare_depart_id UUID REFERENCES gares(id) ON DELETE SET NULL, + gare_depart_nom VARCHAR(255), + heure_depart TIME NOT NULL, + + -- Gare d'arrivée (destination) + gare_arrivee_id UUID REFERENCES gares(id) ON DELETE SET NULL, + gare_arrivee_nom VARCHAR(255), + heure_arrivee TIME NOT NULL, + + -- Jours de circulation (base) + circule_lundi BOOLEAN DEFAULT true, + circule_mardi BOOLEAN DEFAULT true, + circule_mercredi BOOLEAN DEFAULT true, + circule_jeudi BOOLEAN DEFAULT true, + circule_vendredi BOOLEAN DEFAULT true, + circule_samedi BOOLEAN DEFAULT true, + circule_dimanche BOOLEAN DEFAULT true, + + -- Jours spéciaux + circule_jours_feries BOOLEAN DEFAULT false, + circule_dimanches_feries BOOLEAN DEFAULT false, + + -- Jours personnalisés (dates spécifiques de circulation) + jours_personnalises JSONB DEFAULT '[]'::jsonb, + -- Format: [{"date": "2026-01-15", "circule": true}, ...] + + -- Jours de non-circulation (dates où le train ne circule pas) + jours_non_circulation JSONB DEFAULT '[]'::jsonb, + -- Format: ["2026-01-20", "2026-02-14", ...] + + -- Matériel roulant associé + materiel_roulant_id BIGINT REFERENCES "materiel-roulant"(id) ON DELETE SET NULL, + + -- Lien vers la table des lignes (optionnel) + ligne_id UUID REFERENCES lignes(id) ON DELETE SET NULL, + ligne_nom VARCHAR(255), + + -- Service Annuel associé (SA) + service_annuel_id UUID REFERENCES services_annuels(id) ON DELETE SET NULL, + service_annuel_nom VARCHAR(255), + + -- Substitution (autocar) + est_substitution BOOLEAN DEFAULT false, + motif_substitution VARCHAR(255), + + -- Gares desservies (snapshot JSON — copie dénormalisée pour lecture rapide) + gares_desservies JSONB DEFAULT '[]'::jsonb, + -- Format: [{"gare_id": "...", "gare_nom": "...", "heure_arrivee": "HH:MM", "heure_depart": "HH:MM", "ordre": 1}, ...] + + -- Composition du train (liste ordonnée des matériels roulants) + composition_train JSONB DEFAULT '[]'::jsonb, + -- Format: [{"materiel_id": 1, "materiel_nom": "Régiolis", "materiel_nom_technique": "X73500", "materiel_image_url": "...", "position": 1}, ...] + + -- Métadonnées + actif BOOLEAN DEFAULT true, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + deleted_at TIMESTAMP WITH TIME ZONE +); + +-- Table des gares desservies (arrêts intermédiaires) +CREATE TABLE IF NOT EXISTS horaires_gares_desservies ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + horaire_id UUID NOT NULL REFERENCES horaires(id) ON DELETE CASCADE, + + -- Informations de la gare desservie + gare_id UUID REFERENCES gares(id) ON DELETE SET NULL, + gare_nom VARCHAR(255) NOT NULL, + + -- Horaires à cette gare + heure_arrivee TIME, + heure_depart TIME, + + -- Ordre dans le parcours (1 = première gare desservie après départ) + ordre INTEGER NOT NULL DEFAULT 0, + + -- Métadonnées + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +-- Index pour améliorer les performances +CREATE INDEX IF NOT EXISTS idx_horaires_numero_train ON horaires(numero_train); +CREATE INDEX IF NOT EXISTS idx_horaires_gare_depart ON horaires(gare_depart_id); +CREATE INDEX IF NOT EXISTS idx_horaires_gare_arrivee ON horaires(gare_arrivee_id); +CREATE INDEX IF NOT EXISTS idx_horaires_actif ON horaires(actif) WHERE deleted_at IS NULL; +CREATE INDEX IF NOT EXISTS idx_horaires_type_train ON horaires(type_train_id); +CREATE INDEX IF NOT EXISTS idx_horaires_materiel ON horaires(materiel_roulant_id); +CREATE INDEX IF NOT EXISTS idx_horaires_ligne ON horaires(ligne_id); +CREATE INDEX IF NOT EXISTS idx_horaires_service_annuel ON horaires(service_annuel_id); +CREATE INDEX IF NOT EXISTS idx_horaires_substitution ON horaires(est_substitution); + +CREATE INDEX IF NOT EXISTS idx_gares_desservies_horaire ON horaires_gares_desservies(horaire_id); +CREATE INDEX IF NOT EXISTS idx_gares_desservies_ordre ON horaires_gares_desservies(horaire_id, ordre); + +-- Trigger pour mettre à jour updated_at automatiquement +DROP TRIGGER IF EXISTS update_horaires_updated_at ON horaires; +CREATE TRIGGER update_horaires_updated_at + BEFORE UPDATE ON horaires + FOR EACH ROW + EXECUTE FUNCTION update_updated_at_column(); + +DROP TRIGGER IF EXISTS update_horaires_gares_desservies_updated_at ON horaires_gares_desservies; +CREATE TRIGGER update_horaires_gares_desservies_updated_at + BEFORE UPDATE ON horaires_gares_desservies + FOR EACH ROW + EXECUTE FUNCTION update_updated_at_column(); + +-- Commentaires sur les tables +COMMENT ON TABLE horaires IS 'Table principale des horaires (sillons) de trains'; +COMMENT ON TABLE horaires_gares_desservies IS 'Gares intermédiaires desservies par chaque horaire'; + +COMMENT ON COLUMN horaires.numero_train IS 'Numéro unique du train (ex: TER 12345)'; +COMMENT ON COLUMN horaires.type_train IS 'Type de train (TER, TGV, Intercités, etc.)'; +COMMENT ON COLUMN horaires.type_train_id IS 'Référence vers la table types_train pour le type de train'; +COMMENT ON COLUMN horaires.jours_personnalises IS 'Dates spécifiques de circulation au format JSON'; +COMMENT ON COLUMN horaires.jours_non_circulation IS 'Dates où le train ne circule pas au format JSON'; +COMMENT ON COLUMN horaires.est_substitution IS 'Indique si ce sillon est une substitution autocar'; +COMMENT ON COLUMN horaires.motif_substitution IS 'Motif de la substitution (travaux, etc.)'; +COMMENT ON COLUMN horaires.ligne_id IS 'Identifiant de la ligne associée au sillon'; +COMMENT ON COLUMN horaires.ligne_nom IS 'Nom de la ligne associée (copie pour lecture)'; +COMMENT ON COLUMN horaires.service_annuel_id IS 'Identifiant du service annuel associé (SA)'; +COMMENT ON COLUMN horaires.service_annuel_nom IS 'Nom du service annuel associé (copie pour lecture)'; + +COMMENT ON COLUMN horaires.gares_desservies IS 'Snapshot JSON des gares desservies (copie dénormalisée synchronisée avec horaires_gares_desservies)'; +COMMENT ON COLUMN horaires.composition_train IS 'Composition ordonnée du train : liste JSON des matériels roulants affectés au sillon (position, id, nom, image)'; + +COMMENT ON COLUMN horaires_gares_desservies.ordre IS 'Position de la gare dans le parcours (1 = première après départ)'; + +-- ============================================================================ +-- TABLES POUR LE SYSTÈME DE GESTION DES PERTURBATIONS +-- ============================================================================ + +-- Table principale des perturbations +CREATE TABLE IF NOT EXISTS perturbations ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- Référence à l'horaire perturbé + horaire_id UUID NOT NULL REFERENCES horaires(id) ON DELETE CASCADE, + + -- Type de perturbation + type_perturbation VARCHAR(50) NOT NULL CHECK (type_perturbation IN ('retard', 'suppression', 'modification_parcours')), + + -- Informations sur le retard (si applicable) + duree_retard_minutes INTEGER, -- Durée du retard en minutes + + -- Cause de la perturbation + cause VARCHAR(100) NOT NULL CHECK (cause IN ( + 'incident_technique', + 'accident_personne', + 'conditions_meteorologiques', + 'travaux', + 'mouvement_social', + 'intervention_police', + 'affluence', + 'malveillance', + 'autre' + )), + cause_details TEXT, -- Détails supplémentaires sur la cause + + -- Sillon de substitution (si suppression ou modification de parcours) + sillon_substitution_id UUID REFERENCES horaires(id) ON DELETE SET NULL, + + -- Période de validité de la perturbation + date_debut TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + date_fin TIMESTAMP WITH TIME ZONE, + + -- Statut + actif BOOLEAN DEFAULT true, + + -- Notes internes + notes TEXT, + + -- Snapshot complet des perturbations par arrêt (état du formulaire) + -- Permet de restaurer exactement le formulaire lors de la modification + perturbations_arrets JSONB DEFAULT NULL, + + -- Métadonnées + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + deleted_at TIMESTAMP WITH TIME ZONE +); + +-- Table des modifications de parcours (gares supprimées/ajoutées) +CREATE TABLE IF NOT EXISTS perturbations_modifications_parcours ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + perturbation_id UUID NOT NULL REFERENCES perturbations(id) ON DELETE CASCADE, + + -- Type de modification + type_modification VARCHAR(20) NOT NULL CHECK (type_modification IN ('gare_supprimee', 'gare_ajoutee', 'horaire_modifie')), + + -- Gare concernée + gare_id UUID REFERENCES gares(id) ON DELETE SET NULL, + gare_nom VARCHAR(255) NOT NULL, + + -- Nouvel horaire si modification + nouvelle_heure_arrivee TIME, + nouvelle_heure_depart TIME, + + -- Ordre dans le parcours (pour les gares ajoutées) + ordre INTEGER, + + -- Métadonnées + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +-- Index pour améliorer les performances +CREATE INDEX IF NOT EXISTS idx_perturbations_horaire ON perturbations(horaire_id); +CREATE INDEX IF NOT EXISTS idx_perturbations_type ON perturbations(type_perturbation); +CREATE INDEX IF NOT EXISTS idx_perturbations_actif ON perturbations(actif) WHERE deleted_at IS NULL; +CREATE INDEX IF NOT EXISTS idx_perturbations_dates ON perturbations(date_debut, date_fin); +CREATE INDEX IF NOT EXISTS idx_perturbations_sillon_substitution ON perturbations(sillon_substitution_id); + +CREATE INDEX IF NOT EXISTS idx_modif_parcours_perturbation ON perturbations_modifications_parcours(perturbation_id); +CREATE INDEX IF NOT EXISTS idx_modif_parcours_gare ON perturbations_modifications_parcours(gare_id); + +-- Trigger pour mettre à jour updated_at automatiquement +CREATE OR REPLACE FUNCTION update_updated_at_column() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = NOW(); + RETURN NEW; +END; +$$ language 'plpgsql'; + +DROP TRIGGER IF EXISTS update_perturbations_updated_at ON perturbations; +CREATE TRIGGER update_perturbations_updated_at + BEFORE UPDATE ON perturbations + FOR EACH ROW + EXECUTE FUNCTION update_updated_at_column(); + +-- Commentaires sur les tables +COMMENT ON TABLE perturbations IS 'Table des perturbations affectant les horaires de trains'; +COMMENT ON TABLE perturbations_modifications_parcours IS 'Détails des modifications de parcours pour les perturbations'; + +COMMENT ON COLUMN perturbations.type_perturbation IS 'Type de perturbation: retard, suppression, ou modification_parcours'; +COMMENT ON COLUMN perturbations.duree_retard_minutes IS 'Durée du retard en minutes (applicable uniquement pour type=retard)'; +COMMENT ON COLUMN perturbations.cause IS 'Cause de la perturbation'; +COMMENT ON COLUMN perturbations.sillon_substitution_id IS 'Train de substitution proposé (si suppression ou modification)'; +-- ============================================================================ +-- TABLES POUR LE SYSTÈME D'INFORMATIONS +-- ============================================================================ + +-- Table des informations trafic +CREATE TABLE IF NOT EXISTS info_trafic ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + titre VARCHAR(255) NOT NULL, + description TEXT, + type VARCHAR(50) NOT NULL CHECK (type IN ('perturbation', 'travaux', 'greve', 'meteo', 'autre')), + severite VARCHAR(20) NOT NULL CHECK (severite IN ('info', 'warning', 'critical')), + ligne VARCHAR(100), + gare_id UUID REFERENCES gares(id) ON DELETE SET NULL, + date_debut TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + date_fin TIMESTAMP WITH TIME ZONE, + actif BOOLEAN DEFAULT true, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + deleted_at TIMESTAMP WITH TIME ZONE +); + +-- Table des événements +CREATE TABLE IF NOT EXISTS evenements ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + titre VARCHAR(255) NOT NULL, + description TEXT, + lieu VARCHAR(255), + gare_id UUID REFERENCES gares(id) ON DELETE SET NULL, + date_debut TIMESTAMP WITH TIME ZONE NOT NULL, + date_fin TIMESTAMP WITH TIME ZONE, + image_url VARCHAR(500), + lien_externe VARCHAR(500), + actif BOOLEAN DEFAULT true, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + deleted_at TIMESTAMP WITH TIME ZONE +); + +-- Table des actualités +CREATE TABLE IF NOT EXISTS actualites ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + titre VARCHAR(255) NOT NULL, + contenu TEXT NOT NULL, + resume VARCHAR(500), + categorie VARCHAR(50) CHECK (categorie IN ('general', 'service', 'promotion', 'partenariat', 'autre')), + image_url VARCHAR(500), + date_publication TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + auteur VARCHAR(100), + actif BOOLEAN DEFAULT true, + mise_en_avant BOOLEAN DEFAULT false, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + deleted_at TIMESTAMP WITH TIME ZONE +); + +-- Index pour améliorer les performances +CREATE INDEX IF NOT EXISTS idx_info_trafic_actif ON info_trafic(actif) WHERE deleted_at IS NULL; +CREATE INDEX IF NOT EXISTS idx_info_trafic_dates ON info_trafic(date_debut, date_fin); +CREATE INDEX IF NOT EXISTS idx_evenements_actif ON evenements(actif) WHERE deleted_at IS NULL; +CREATE INDEX IF NOT EXISTS idx_evenements_dates ON evenements(date_debut, date_fin); +CREATE INDEX IF NOT EXISTS idx_actualites_actif ON actualites(actif) WHERE deleted_at IS NULL; +CREATE INDEX IF NOT EXISTS idx_actualites_publication ON actualites(date_publication); + +-- Trigger pour mettre à jour updated_at automatiquement +CREATE OR REPLACE FUNCTION update_updated_at_column() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = NOW(); + RETURN NEW; +END; +$$ language 'plpgsql'; + +DROP TRIGGER IF EXISTS update_info_trafic_updated_at ON info_trafic; +CREATE TRIGGER update_info_trafic_updated_at + BEFORE UPDATE ON info_trafic + FOR EACH ROW + EXECUTE FUNCTION update_updated_at_column(); + +DROP TRIGGER IF EXISTS update_evenements_updated_at ON evenements; +CREATE TRIGGER update_evenements_updated_at + BEFORE UPDATE ON evenements + FOR EACH ROW + EXECUTE FUNCTION update_updated_at_column(); + +DROP TRIGGER IF EXISTS update_actualites_updated_at ON actualites; +CREATE TRIGGER update_actualites_updated_at + BEFORE UPDATE ON actualites + FOR EACH ROW + EXECUTE FUNCTION update_updated_at_column(); + +-- Commentaires sur les tables +COMMENT ON TABLE info_trafic IS 'Informations de trafic (perturbations, travaux, etc.)'; +COMMENT ON TABLE evenements IS 'Événements à venir dans les gares ou sur le réseau'; +COMMENT ON TABLE actualites IS 'Actualités et nouvelles du réseau'; +-- ============================================================================ +-- MIGRATION: Système de Gestion d'Annonces Sonores +-- ============================================================================ +-- À exécuter dans l'interface SQL de Supabase (SQL Editor) +-- ============================================================================ + +-- 1. TABLE banque_mots +-- Métadonnées des fichiers audio individuels (mots/sons) de la banque +-- ============================================================================ +CREATE TABLE IF NOT EXISTS public.banque_mots ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + label TEXT NOT NULL, -- Libellé affiché (ex: "Bonjour", "Mesdames et messieurs") + nom_fichier TEXT NOT NULL, -- Nom du fichier dans le storage (ex: "bonjour.mp3") + storage_path TEXT NOT NULL, -- Chemin complet dans le bucket (ex: "mots/salutation_bonjour.mp3") + categorie TEXT NOT NULL DEFAULT 'general', -- 'salutation' | 'signal' | 'destination' | 'numero' | 'temps' | 'perturbation' | 'quai' | 'general' + duree_ms INTEGER, -- Durée du fichier audio en millisecondes + format TEXT NOT NULL DEFAULT 'mp3', -- 'mp3' | 'wav' | 'ogg' + taille_bytes BIGINT, -- Taille du fichier en octets + description TEXT, -- Description optionnelle + actif BOOLEAN NOT NULL DEFAULT true, + created_by UUID REFERENCES auth.users(id), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + deleted_at TIMESTAMPTZ +); + +-- Index pour recherche rapide par catégorie et label +CREATE INDEX IF NOT EXISTS idx_banque_mots_categorie ON public.banque_mots(categorie); +CREATE INDEX IF NOT EXISTS idx_banque_mots_label ON public.banque_mots(label); +CREATE INDEX IF NOT EXISTS idx_banque_mots_actif ON public.banque_mots(actif) WHERE deleted_at IS NULL; + +-- 2. TABLE annonces_templates +-- Templates d'annonces définis par concaténation de mots de la banque +-- ============================================================================ +CREATE TABLE IF NOT EXISTS public.annonces_templates ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + nom TEXT NOT NULL, -- Nom du template (ex: "Départ standard") + type_annonce TEXT NOT NULL, -- 'depart' | 'arrivee' | 'manoeuvre' | 'passage' | 'retard' | 'supprime' + description TEXT, -- Description optionnelle du template + -- Séquence ordonnée des éléments du template + -- Chaque élément: { type: 'mot' | 'variable' | 'pause', mot_id?: UUID, variable?: string, pause_ms?: number } + sequence JSONB NOT NULL DEFAULT '[]', -- Array d'éléments ordonnés + variables JSONB NOT NULL DEFAULT '[]', -- Array des noms de variables attendues (ex: ["numero_train", "destination"]) + tts_texte TEXT, -- Texte TTS alternatif pour prévisualisation rapide + actif BOOLEAN NOT NULL DEFAULT true, + created_by UUID REFERENCES auth.users(id), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + deleted_at TIMESTAMPTZ +); + +-- Index par type d'annonce +CREATE INDEX IF NOT EXISTS idx_templates_type ON public.annonces_templates(type_annonce); +CREATE INDEX IF NOT EXISTS idx_templates_actif ON public.annonces_templates(actif) WHERE deleted_at IS NULL; + +-- 3. TABLE annonces_enregistrees +-- Annonces complètes générées et sauvegardées +-- ============================================================================ +CREATE TABLE IF NOT EXISTS public.annonces_enregistrees ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + nom TEXT NOT NULL, -- Nom de l'annonce (ex: "TER 12345 départ Paris 14h30") + type_annonce TEXT NOT NULL, -- Même enum que annonces_templates.type_annonce + template_id UUID REFERENCES public.annonces_templates(id), + storage_path TEXT NOT NULL, -- Chemin dans le bucket storage + nom_fichier TEXT NOT NULL, -- Nom du fichier final + variables_values JSONB NOT NULL DEFAULT '{}', -- Valeurs des variables utilisées + mode_generation TEXT NOT NULL DEFAULT 'tts', -- 'tts' | 'concatenation' + duree_ms INTEGER, + taille_bytes BIGINT, + gare_id UUID REFERENCES public.gares(id), + diffuse BOOLEAN NOT NULL DEFAULT false, + date_diffusion TIMESTAMPTZ, + notes TEXT, + created_by UUID REFERENCES auth.users(id), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + deleted_at TIMESTAMPTZ +); + +-- Index de recherche +CREATE INDEX IF NOT EXISTS idx_annonces_type ON public.annonces_enregistrees(type_annonce); +CREATE INDEX IF NOT EXISTS idx_annonces_gare ON public.annonces_enregistrees(gare_id); +CREATE INDEX IF NOT EXISTS idx_annonces_created ON public.annonces_enregistrees(created_at DESC); + +-- 4. TRIGGER updated_at automatique +-- ============================================================================ +CREATE OR REPLACE FUNCTION public.update_updated_at_column() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = now(); + RETURN NEW; +END; +$$ language 'plpgsql'; + +DROP TRIGGER IF EXISTS set_banque_mots_updated_at ON public.banque_mots; +CREATE TRIGGER set_banque_mots_updated_at + BEFORE UPDATE ON public.banque_mots + FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column(); + +DROP TRIGGER IF EXISTS set_templates_updated_at ON public.annonces_templates; +CREATE TRIGGER set_templates_updated_at + BEFORE UPDATE ON public.annonces_templates + FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column(); + +DROP TRIGGER IF EXISTS set_annonces_updated_at ON public.annonces_enregistrees; +CREATE TRIGGER set_annonces_updated_at + BEFORE UPDATE ON public.annonces_enregistrees + FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column(); + +-- 5. RLS (Row Level Security) +-- ============================================================================ +ALTER TABLE public.banque_mots ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.annonces_templates ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.annonces_enregistrees ENABLE ROW LEVEL SECURITY; + +CREATE POLICY "Authenticated users can read banque_mots" + ON public.banque_mots FOR SELECT TO authenticated + USING (deleted_at IS NULL); + +CREATE POLICY "Authenticated users can insert banque_mots" + ON public.banque_mots FOR INSERT TO authenticated + WITH CHECK (true); + +CREATE POLICY "Authenticated users can update banque_mots" + ON public.banque_mots FOR UPDATE TO authenticated + USING (true); + +CREATE POLICY "Authenticated users can read templates" + ON public.annonces_templates FOR SELECT TO authenticated + USING (deleted_at IS NULL); + +CREATE POLICY "Authenticated users can insert templates" + ON public.annonces_templates FOR INSERT TO authenticated + WITH CHECK (true); + +CREATE POLICY "Authenticated users can update templates" + ON public.annonces_templates FOR UPDATE TO authenticated + USING (true); + +CREATE POLICY "Authenticated users can read annonces" + ON public.annonces_enregistrees FOR SELECT TO authenticated + USING (deleted_at IS NULL); + +CREATE POLICY "Authenticated users can insert annonces" + ON public.annonces_enregistrees FOR INSERT TO authenticated + WITH CHECK (true); + +CREATE POLICY "Authenticated users can update annonces" + ON public.annonces_enregistrees FOR UPDATE TO authenticated + USING (true); + +-- 6. STORAGE BUCKET +-- ============================================================================ +INSERT INTO storage.buckets (id, name, public, file_size_limit, allowed_mime_types) +VALUES ( + 'banque-annonces', + 'banque-annonces', + true, + 52428800, -- 50 MB max par fichier + ARRAY['audio/mpeg', 'audio/mp3', 'audio/wav', 'audio/wave', 'audio/ogg', 'audio/webm'] +) +ON CONFLICT (id) DO NOTHING; + +-- Politiques Storage +CREATE POLICY "Public read access on banque-annonces" + ON storage.objects FOR SELECT TO public + USING (bucket_id = 'banque-annonces'); + +CREATE POLICY "Authenticated users can upload to banque-annonces" + ON storage.objects FOR INSERT TO authenticated + WITH CHECK (bucket_id = 'banque-annonces'); + +CREATE POLICY "Authenticated users can delete from banque-annonces" + ON storage.objects FOR DELETE TO authenticated + USING (bucket_id = 'banque-annonces'); + +-- ============================================================================ +-- CONVENTION DE NOMMAGE DES FICHIERS AUDIO +-- ============================================================================ +-- Fichiers dans banque-annonces/mots/ : +-- Format: [categorie]_[label-kebab-case].[ext] +-- Exemples: +-- salutation_bonjour.mp3 +-- salutation_mesdames-et-messieurs.mp3 +-- signal_gong-arrivee.mp3 +-- signal_gong-depart.mp3 +-- destination_paris-gare-de-lyon.mp3 +-- temps_a-destination-de.mp3 +-- quai_voie-1.mp3 +-- numero_ter.mp3 +-- perturbation_ce-train-est-supprime.mp3 +-- general_attention.mp3 +-- +-- Annonces générées dans banque-annonces/annonces/ : +-- Format: [type]_[YYYY-MM-DD]_[uuid-court].[ext] +-- Exemple: depart_2026-07-02_a1b2c3.mp3 +-- ============================================================================ +-- Table pour la gestion des pages (CMS) +CREATE TABLE IF NOT EXISTS pages ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + titre TEXT NOT NULL, + slug TEXT UNIQUE NOT NULL, + contenu JSONB DEFAULT '[]', -- Liste ordonnée de composants + statut TEXT DEFAULT 'brouillon', -- 'publie', 'brouillon' + region_id VARCHAR(255), + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + deleted_at TIMESTAMP WITH TIME ZONE +); + +-- Index pour accélérer la recherche par slug et region +CREATE INDEX IF NOT EXISTS idx_pages_slug ON pages(slug); +CREATE INDEX IF NOT EXISTS idx_pages_region ON pages(region_id); +-- ============================================================================ +-- MIGRATION : Ajout du système de régions +-- Description : Ajout des colonnes region_id/region_nom aux tables concernées +-- Date : 2026-02-07 +-- ============================================================================ + +-- ============================================================================ +-- TABLE : utilisateurs - Ajout colonne region_id +-- ============================================================================ +ALTER TABLE utilisateurs ADD COLUMN IF NOT EXISTS region_id VARCHAR(100); +COMMENT ON COLUMN utilisateurs.region_id IS 'Identifiant de la région administrée par l''utilisateur'; + +-- Index sur region_id +CREATE INDEX IF NOT EXISTS idx_utilisateurs_region_id ON utilisateurs(region_id); + +-- ============================================================================ +-- TABLE : info_trafic - Ajout colonnes région +-- ============================================================================ +ALTER TABLE info_trafic ADD COLUMN IF NOT EXISTS region_id VARCHAR(100); +ALTER TABLE info_trafic ADD COLUMN IF NOT EXISTS created_by UUID REFERENCES utilisateurs(id); + +COMMENT ON COLUMN info_trafic.region_id IS 'Identifiant de la région concernée par l''info trafic'; +COMMENT ON COLUMN info_trafic.created_by IS 'Utilisateur ayant créé l''info trafic'; + +-- Index +CREATE INDEX IF NOT EXISTS idx_info_trafic_region_id ON info_trafic(region_id); +CREATE INDEX IF NOT EXISTS idx_info_trafic_created_by ON info_trafic(created_by); + +-- ============================================================================ +-- TABLE : evenements - Ajout colonnes région +-- ============================================================================ +ALTER TABLE evenements ADD COLUMN IF NOT EXISTS region_id VARCHAR(100); +ALTER TABLE evenements ADD COLUMN IF NOT EXISTS created_by UUID REFERENCES utilisateurs(id); + +COMMENT ON COLUMN evenements.region_id IS 'Identifiant de la région concernée par l''événement'; +COMMENT ON COLUMN evenements.created_by IS 'Utilisateur ayant créé l''événement'; + +-- Index +CREATE INDEX IF NOT EXISTS idx_evenements_region_id ON evenements(region_id); +CREATE INDEX IF NOT EXISTS idx_evenements_created_by ON evenements(created_by); + +-- ============================================================================ +-- TABLE : actualites - Ajout colonnes région +-- ============================================================================ +ALTER TABLE actualites ADD COLUMN IF NOT EXISTS region_id VARCHAR(100); +ALTER TABLE actualites ADD COLUMN IF NOT EXISTS created_by UUID REFERENCES utilisateurs(id); + +COMMENT ON COLUMN actualites.region_id IS 'Identifiant de la région concernée par l''actualité'; +COMMENT ON COLUMN actualites.created_by IS 'Utilisateur ayant créé l''actualité'; + +-- Index +CREATE INDEX IF NOT EXISTS idx_actualites_region_id ON actualites(region_id); +CREATE INDEX IF NOT EXISTS idx_actualites_created_by ON actualites(created_by); + +-- ============================================================================ +-- TABLE : perturbations - Ajout colonnes région (si applicable) +-- ============================================================================ +ALTER TABLE perturbations ADD COLUMN IF NOT EXISTS region_id VARCHAR(100); +ALTER TABLE perturbations ADD COLUMN IF NOT EXISTS created_by UUID REFERENCES utilisateurs(id); + +COMMENT ON COLUMN perturbations.region_id IS 'Identifiant de la région concernée par la perturbation'; +COMMENT ON COLUMN perturbations.created_by IS 'Utilisateur ayant créé la perturbation'; + +-- Index +CREATE INDEX IF NOT EXISTS idx_perturbations_region_id ON perturbations(region_id); +CREATE INDEX IF NOT EXISTS idx_perturbations_created_by ON perturbations(created_by); + +-- ============================================================================ +-- TABLE : travaux - Ajout colonnes région (si la table existe) +-- ============================================================================ +-- Note: À décommenter si la table travaux existe déjà +-- ALTER TABLE travaux ADD COLUMN IF NOT EXISTS region_id VARCHAR(100); +-- ALTER TABLE travaux ADD COLUMN IF NOT EXISTS created_by UUID REFERENCES utilisateurs(id); +-- COMMENT ON COLUMN travaux.region_id IS 'Identifiant de la région concernée par les travaux'; +-- COMMENT ON COLUMN travaux.created_by IS 'Utilisateur ayant créé les travaux'; +-- CREATE INDEX IF NOT EXISTS idx_travaux_region_id ON travaux(region_id); +-- CREATE INDEX IF NOT EXISTS idx_travaux_created_by ON travaux(created_by); + +-- ============================================================================ +-- VUES : Vues pratiques pour filtrer par région +-- ============================================================================ + +-- Vue : Informations par région +CREATE OR REPLACE VIEW v_info_trafic_par_region AS +SELECT + it.*, + u.nom as createur_nom, + u.prenom as createur_prenom, + u.email as createur_email +FROM info_trafic it +LEFT JOIN utilisateurs u ON it.created_by = u.id +ORDER BY it.created_at DESC; + +-- Vue : Événements par région +CREATE OR REPLACE VIEW v_evenements_par_region AS +SELECT + e.*, + u.nom as createur_nom, + u.prenom as createur_prenom, + u.email as createur_email +FROM evenements e +LEFT JOIN utilisateurs u ON e.created_by = u.id +ORDER BY e.created_at DESC; + +-- Vue : Actualités par région +CREATE OR REPLACE VIEW v_actualites_par_region AS +SELECT + a.*, + u.nom as createur_nom, + u.prenom as createur_prenom, + u.email as createur_email +FROM actualites a +LEFT JOIN utilisateurs u ON a.created_by = u.id +ORDER BY a.created_at DESC; + +-- ============================================================================ +-- DONNÉES DE TEST (optionnel) +-- ============================================================================ + +-- Exemple : Associer un utilisateur existant à une région +-- UPDATE utilisateurs SET region_id = 'ile-de-france' WHERE email = 'admin@example.com'; + +-- ============================================================================ +-- NETTOYAGE : Supprimer l'ancienne colonne region_nom si elle existe +-- ============================================================================ +-- Note: Migrer les données avant de supprimer +-- UPDATE utilisateurs SET region_id = LOWER(REPLACE(region_nom, ' ', '-')) WHERE region_nom IS NOT NULL; +-- ALTER TABLE utilisateurs DROP COLUMN IF EXISTS region_nom; + +-- ============================================================================ +-- MIGRATION : Amélioration des tables événements et actualités +-- Description : Ajout des champs pour gestion complète des événements et actualités +-- Date : 2026-02-07 +-- ============================================================================ + +-- ============================================================================ +-- TABLE : evenements - Ajout colonnes manquantes +-- ============================================================================ + +-- Ajouter colonne visible_site (correspond à "Visible sur le site") +ALTER TABLE evenements ADD COLUMN IF NOT EXISTS visible_site BOOLEAN DEFAULT true; + +-- S'assurer que region_id existe (ajouté précédemment mais on vérifie) +ALTER TABLE evenements ADD COLUMN IF NOT EXISTS region_id VARCHAR(100); + +-- S'assurer que created_by existe (ajouté précédemment mais on vérifie) +ALTER TABLE evenements ADD COLUMN IF NOT EXISTS created_by UUID REFERENCES utilisateurs(id); + +-- Commentaires +COMMENT ON COLUMN evenements.visible_site IS 'Indique si l''événement est visible sur le site public'; +COMMENT ON COLUMN evenements.region_id IS 'Identifiant de la région concernée par l''événement'; +COMMENT ON COLUMN evenements.created_by IS 'Utilisateur ayant créé l''événement'; +COMMENT ON COLUMN evenements.titre IS 'Titre de l''événement'; +COMMENT ON COLUMN evenements.description IS 'Description détaillée de l''événement'; + +-- Index supplémentaires +CREATE INDEX IF NOT EXISTS idx_evenements_visible_site ON evenements(visible_site) WHERE deleted_at IS NULL; + +-- ============================================================================ +-- TABLE : actualites - Ajout colonnes manquantes +-- ============================================================================ + +-- Renommer image_url en image_couverture pour plus de clarté +-- Note: PostgreSQL ne permet pas de renommer facilement, on ajoute une nouvelle colonne +ALTER TABLE actualites ADD COLUMN IF NOT EXISTS image_couverture VARCHAR(500); + +-- Copier les données de image_url vers image_couverture si image_url existe +UPDATE actualites SET image_couverture = image_url WHERE image_couverture IS NULL AND image_url IS NOT NULL; + +-- S'assurer que region_id existe +ALTER TABLE actualites ADD COLUMN IF NOT EXISTS region_id VARCHAR(100); + +-- S'assurer que created_by existe +ALTER TABLE actualites ADD COLUMN IF NOT EXISTS created_by UUID REFERENCES utilisateurs(id); + +-- Commentaires +COMMENT ON COLUMN actualites.image_couverture IS 'URL de l''image de couverture de l''actualité'; +COMMENT ON COLUMN actualites.region_id IS 'Identifiant de la région concernée par l''actualité'; +COMMENT ON COLUMN actualites.created_by IS 'Utilisateur ayant créé l''actualité'; +COMMENT ON COLUMN actualites.titre IS 'Titre de l''actualité'; +COMMENT ON COLUMN actualites.contenu IS 'Contenu complet de l''actualité (peut être HTML)'; +COMMENT ON COLUMN actualites.resume IS 'Résumé court de l''actualité'; + +-- ============================================================================ +-- VUES : Amélioration des vues existantes +-- ============================================================================ + +-- Vue améliorée pour les événements +CREATE OR REPLACE VIEW v_evenements_complets AS +SELECT + e.*, + u.nom as createur_nom, + u.prenom as createur_prenom, + u.email as createur_email, + g.nom as gare_nom, + g.ville as gare_ville +FROM evenements e +LEFT JOIN utilisateurs u ON e.created_by = u.id +LEFT JOIN gares g ON e.gare_id = g.id +WHERE e.deleted_at IS NULL +ORDER BY e.date_debut DESC; + +-- Vue améliorée pour les actualités +CREATE OR REPLACE VIEW v_actualites_completes AS +SELECT + a.*, + u.nom as createur_nom, + u.prenom as createur_prenom, + u.email as createur_email +FROM actualites a +LEFT JOIN utilisateurs u ON a.created_by = u.id +WHERE a.deleted_at IS NULL +ORDER BY a.date_publication DESC; + +-- ============================================================================ +-- DONNÉES DE TEST (optionnel - à décommenter si besoin) +-- ============================================================================ + +/* +-- Exemple d'événement +INSERT INTO evenements (titre, description, lieu, date_debut, date_fin, visible_site, region_id, actif) +VALUES ( + 'Portes ouvertes Gare de Lyon', + 'Venez découvrir les coulisses de la gare de Lyon lors de nos portes ouvertes exceptionnelles.', + 'Gare de Lyon - Paris', + NOW() + INTERVAL '7 days', + NOW() + INTERVAL '7 days' + INTERVAL '8 hours', + true, + 'ile-de-france', + true +); + +-- Exemple d'actualité +INSERT INTO actualites (titre, contenu, resume, image_couverture, region_id, actif) +VALUES ( + 'Nouvelle ligne TER en service', + 'Une nouvelle ligne TER relie désormais Paris à Orléans avec des arrêts supplémentaires pour faciliter vos déplacements quotidiens. Cette ligne propose 20 allers-retours par jour...', + 'Mise en service d''une nouvelle ligne TER Paris-Orléans', + '/images/actualites/ter-paris-orleans.jpg', + 'ile-de-france', + true +); +*/ + +-- ============================================================================ +-- NETTOYAGE : Migration de l'ancienne colonne image_url (optionnel) +-- ============================================================================ + +/* +-- À exécuter APRÈS avoir vérifié que toutes les données sont migrées +-- ALTER TABLE actualites DROP COLUMN IF EXISTS image_url; +*/ + +-- ============================================================================ +-- PERMISSIONS RLS (à adapter selon vos besoins) +-- ============================================================================ + +-- Activer RLS sur les tables (optionnel - à adapter selon votre architecture Auth) +-- ALTER TABLE evenements ENABLE ROW LEVEL SECURITY; +-- ALTER TABLE actualites ENABLE ROW LEVEL SECURITY; + +-- Exemple de policies (à adapter) +/* +-- Tout le monde peut lire les événements visibles +CREATE POLICY "Les événements visibles sont publics" +ON evenements FOR SELECT +USING (visible_site = true AND actif = true AND deleted_at IS NULL); + +-- Seuls les créateurs et admins peuvent modifier +CREATE POLICY "Les créateurs peuvent modifier leurs événements" +ON evenements FOR UPDATE +USING (created_by = auth.uid() OR auth.jwt() -> 'user_metadata' ->> 'role' = 'admin'); + +-- Tout le monde peut lire les actualités actives +CREATE POLICY "Les actualités actives sont publiques" +ON actualites FOR SELECT +USING (actif = true AND deleted_at IS NULL); + +-- Seuls les créateurs et admins peuvent modifier +CREATE POLICY "Les créateurs peuvent modifier leurs actualités" +ON actualites FOR UPDATE +USING (created_by = auth.uid() OR auth.jwt() -> 'user_metadata' ->> 'role' = 'admin'); +*/ + +-- Création de la table regles_quais +CREATE TABLE IF NOT EXISTS public.regles_quais ( + id UUID DEFAULT uuid_generate_v4() PRIMARY KEY, + gare_id UUID NOT NULL REFERENCES public.gares(id) ON DELETE CASCADE, + ligne_id UUID REFERENCES public.lignes(id) ON DELETE CASCADE, + type_regle TEXT NOT NULL CHECK (type_regle IN ('INTERDIT', 'PREFERENTIEL')), + quais_concernes JSONB NOT NULL DEFAULT '[]'::jsonb, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +-- Index pour la recherche par gare +CREATE INDEX IF NOT EXISTS idx_regles_quais_gare ON public.regles_quais(gare_id); +-- Migration : Ajout de la colonne fh_url à la table lignes +-- Stocke l'URL publique du PDF de la fiche horaire associée à la ligne + +ALTER TABLE lignes + ADD COLUMN IF NOT EXISTS fh_url TEXT DEFAULT NULL; + +COMMENT ON COLUMN lignes.fh_url IS 'URL publique du fichier PDF de la fiche horaire de la ligne (stocké dans Supabase Storage)'; +-- Migration: create table horaires_substitutions +-- Cette table est utilisée par l'API front-end (src/api/horairesSubstitutions.js) +CREATE TABLE IF NOT EXISTS horaires_substitutions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- Référence éventuelle vers un horaire source + horaire_id UUID REFERENCES horaires(id) ON DELETE SET NULL, + + -- Région (filtrage RBAC) + region_id UUID, + region VARCHAR(255), + + -- Informations générales (copie similaire à `horaires`) + numero_train VARCHAR(50), + type_train VARCHAR(100), + type_train_id BIGINT, + + gare_depart_id UUID, + gare_depart_nom VARCHAR(255), + heure_depart TIME, + + gare_arrivee_id UUID, + gare_arrivee_nom VARCHAR(255), + heure_arrivee TIME, + + -- Période de validité de la substitution + date_debut TIMESTAMP WITH TIME ZONE, + date_fin TIMESTAMP WITH TIME ZONE, + + -- Jours / exceptions + circule_lundi BOOLEAN DEFAULT true, + circule_mardi BOOLEAN DEFAULT true, + circule_mercredi BOOLEAN DEFAULT true, + circule_jeudi BOOLEAN DEFAULT true, + circule_vendredi BOOLEAN DEFAULT true, + circule_samedi BOOLEAN DEFAULT true, + circule_dimanche BOOLEAN DEFAULT true, + circule_jours_feries BOOLEAN DEFAULT false, + + jours_personnalises JSONB DEFAULT '[]'::jsonb, + jours_non_circulation JSONB DEFAULT '[]'::jsonb, + + materiel_roulant_id BIGINT, + + ligne_id UUID, + ligne_nom VARCHAR(255), + + service_annuel_id UUID, + service_annuel_nom VARCHAR(255), + + -- Indique qu'il s'agit d'une substitution + est_substitution BOOLEAN DEFAULT true, + motif_substitution VARCHAR(255), + + -- Snapshot des gares desservies et composition + gares_desservies JSONB DEFAULT '[]'::jsonb, + composition_train JSONB DEFAULT '[]'::jsonb, + + -- Métadonnées + actif BOOLEAN DEFAULT true, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + deleted_at TIMESTAMP WITH TIME ZONE +); + +-- Indexes utiles +CREATE INDEX IF NOT EXISTS idx_horaires_substitutions_region ON horaires_substitutions(region_id); +CREATE INDEX IF NOT EXISTS idx_horaires_substitutions_horaire ON horaires_substitutions(horaire_id); +CREATE INDEX IF NOT EXISTS idx_horaires_substitutions_actif ON horaires_substitutions(actif) WHERE deleted_at IS NULL; +CREATE INDEX IF NOT EXISTS idx_horaires_substitutions_datefin ON horaires_substitutions(date_fin); + +-- Trigger pour updated_at si la fonction existe +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM pg_proc WHERE proname = 'update_updated_at_column') THEN + DROP TRIGGER IF EXISTS update_horaires_substitutions_updated_at ON horaires_substitutions; + CREATE TRIGGER update_horaires_substitutions_updated_at + BEFORE UPDATE ON horaires_substitutions + FOR EACH ROW + EXECUTE FUNCTION update_updated_at_column(); + END IF; +END$$; + +COMMENT ON TABLE horaires_substitutions IS 'Table des horaires de substitution (autocar / remplacement)'; +COMMENT ON COLUMN horaires_substitutions.gares_desservies IS 'Liste JSON des gares desservies par la substitution'; +COMMENT ON COLUMN horaires_substitutions.composition_train IS 'Composition JSON du matériel roulant (position, id, nom)'; +COMMENT ON COLUMN horaires_substitutions.date_fin IS 'Date/heure de fin de validité de la substitution'; + +-- ============================================================================ +-- MIGRATION : Ajout de la colonne composition_train (JSONB) dans la table horaires +-- Date : 2026-03-01 +-- Description : Stocke la composition complète et ordonnée des matériels roulants +-- affectés à un horaire (sillon). Chaque élément contient l'id, +-- le nom, le nom technique, l'URL de l'image et la position. +-- +-- ⚠️ À EXÉCUTER dans l'éditeur SQL Supabase : +-- https://supabase.com/dashboard → votre projet → SQL Editor +-- ============================================================================ + +ALTER TABLE public.horaires + ADD COLUMN IF NOT EXISTS composition_train JSONB DEFAULT '[]'::jsonb; + +COMMENT ON COLUMN public.horaires.composition_train IS + 'Composition ordonnée du train : liste JSON des matériels roulants affectés au sillon. ' + 'Format : [{"materiel_id": 1, "materiel_nom": "Régiolis", "materiel_nom_technique": "X73500", "materiel_image_url": "https://...", "position": 1}, ...]'; + +-- ============================================================================ +-- MIGRATION : Ajout de la colonne informations (JSONB) dans la table gares +-- Date : 2026-03-01 +-- Description : Stocke toutes les informations enrichies d'une gare : +-- adresse, horaires, accessibilité, services, voiture, vélo, +-- et lignes de transports en commun (bus, métro, tramway, car régional). +-- +-- ⚠️ À EXÉCUTER dans l'éditeur SQL Supabase : +-- https://supabase.com/dashboard → votre projet → SQL Editor +-- ============================================================================ + +ALTER TABLE public.gares + ADD COLUMN IF NOT EXISTS informations JSONB DEFAULT '{}'::jsonb; + +COMMENT ON COLUMN public.gares.informations IS + 'Informations enrichies de la gare au format JSON. Structure : + { + -- Informations Générales + "adresse": "20 Bd Diderot, 75012 Paris", + "horaires_guichet": "6h00 - 21h30", + "horaires_gare": "5h00 - 00h30", + "distributeur_ter": true, + + -- Accessibilité + "service_assistance": true, + + -- Services + "espace_attente": true, + "distributeur_tgv": false, + "objets_trouves": true, + "toilettes": true, + "wifi_gratuit": true, + "photomaton": false, + "defibrillateur": true, + "galeries_marchandes": false, + + -- Voiture + "depose_minute": true, + "espace_taxis": true, + "espace_vtc": false, + "location_voiture": false, + "parking": true, + + -- Vélo + "velo_libre_service": true, + "parking_velos": true, + + -- Transports en commun enrichis + "lignes_bus": [{"nom": "29", "terminus_a": "Gare Saint-Lazare", "terminus_b": "Montempoivre"}], + "lignes_metro": [{"nom": "1", "terminus_a": "La Défense", "terminus_b": "Château de Vincennes"}], + "lignes_tramway": [], + "lignes_car_regional": [] + }'; + +-- ============================================================================ +-- MIGRATION : Ajout de la colonne gares_desservies (JSONB) dans la table horaires +-- Date : 2026-03-01 +-- Description : Ajoute une colonne JSONB "gares_desservies" dans la table horaires +-- afin de stocker un snapshot dénormalisé des gares desservies. +-- Cette colonne est alimentée à chaque création/modification d'horaire +-- et est synchronisée avec la table relationnelle horaires_gares_desservies. +-- ============================================================================ + +-- Ajout de la colonne si elle n'existe pas encore +ALTER TABLE public.horaires + ADD COLUMN IF NOT EXISTS gares_desservies JSONB DEFAULT '[]'::jsonb; + +-- Commentaire explicatif sur la colonne +COMMENT ON COLUMN public.horaires.gares_desservies IS + 'Snapshot JSON des gares desservies (copie dénormalisée synchronisée avec horaires_gares_desservies). ' + 'Format : [{"gare_id": "uuid", "gare_nom": "Nom Gare", "heure_arrivee": "HH:MM", "heure_depart": "HH:MM", "ordre": 1}, ...]'; + +-- ============================================================================ +-- Rétro-remplissage : synchroniser la colonne JSON depuis la table relationnelle +-- pour tous les horaires existants ayant déjà des gares desservies. +-- ============================================================================ +UPDATE public.horaires h +SET gares_desservies = ( + SELECT COALESCE( + jsonb_agg( + jsonb_build_object( + 'gare_id', gd.gare_id, + 'gare_nom', gd.gare_nom, + 'heure_arrivee', gd.heure_arrivee, + 'heure_depart', gd.heure_depart, + 'ordre', gd.ordre + ) ORDER BY gd.ordre + ), + '[]'::jsonb + ) + FROM public.horaires_gares_desservies gd + WHERE gd.horaire_id = h.id +) +WHERE EXISTS ( + SELECT 1 FROM public.horaires_gares_desservies gd WHERE gd.horaire_id = h.id +); + +-- ============================================================================ +-- (Optionnel) Trigger pour maintenir automatiquement la colonne JSON à jour +-- à chaque INSERT / UPDATE / DELETE dans horaires_gares_desservies. +-- Décommenter si vous souhaitez une synchronisation automatique côté base. +-- ============================================================================ + +-- CREATE OR REPLACE FUNCTION public.sync_horaire_gares_desservies_json() +-- RETURNS trigger LANGUAGE plpgsql AS $$ +-- DECLARE +-- v_horaire_id UUID; +-- BEGIN +-- IF TG_OP = 'DELETE' THEN +-- v_horaire_id := OLD.horaire_id; +-- ELSE +-- v_horaire_id := NEW.horaire_id; +-- END IF; +-- +-- UPDATE public.horaires +-- SET gares_desservies = ( +-- SELECT COALESCE( +-- jsonb_agg( +-- jsonb_build_object( +-- 'gare_id', gd.gare_id, +-- 'gare_nom', gd.gare_nom, +-- 'heure_arrivee', gd.heure_arrivee, +-- 'heure_depart', gd.heure_depart, +-- 'ordre', gd.ordre +-- ) ORDER BY gd.ordre +-- ), +-- '[]'::jsonb +-- ) +-- FROM public.horaires_gares_desservies gd +-- WHERE gd.horaire_id = v_horaire_id +-- ) +-- WHERE id = v_horaire_id; +-- +-- RETURN NULL; +-- END; +-- $$; +-- +-- DROP TRIGGER IF EXISTS tr_sync_gares_desservies_json ON public.horaires_gares_desservies; +-- CREATE TRIGGER tr_sync_gares_desservies_json +-- AFTER INSERT OR UPDATE OR DELETE ON public.horaires_gares_desservies +-- FOR EACH ROW EXECUTE FUNCTION public.sync_horaire_gares_desservies_json(); + +-- Migration : ajout de la colonne jours_perturbation à la table perturbations +-- Permet de stocker plusieurs jours uniques ou périodes pour une perturbation + +ALTER TABLE perturbations + ADD COLUMN IF NOT EXISTS jours_perturbation JSONB DEFAULT NULL; + +COMMENT ON COLUMN perturbations.jours_perturbation IS + 'Liste des jours/périodes de perturbation. Format : [{type: "jour"|"periode", date: "YYYY-MM-DD", dateDebut: "YYYY-MM-DD", dateFin: "YYYY-MM-DD"}]'; + +-- Ajoute un champ JSONB pour stocker les permissions par fonctionnalite. +ALTER TABLE IF EXISTS public.utilisateurs +ADD COLUMN IF NOT EXISTS permissions jsonb NOT NULL DEFAULT '{}'::jsonb; + +-- Securise les lignes existantes si la colonne etait nullable. +UPDATE public.utilisateurs +SET permissions = '{}'::jsonb +WHERE permissions IS NULL; + +-- ============================================================================ +-- MIGRATION : Ajout de la colonne perturbations_arrets (JSONB) dans perturbations +-- Date : 2026-03-01 +-- Description : Stocke l'état complet des perturbations par arrêt tel que +-- configuré dans le formulaire de création / modification. +-- Ceci permet au formulaire d'édition de restaurer exactement +-- le même état que le formulaire de création. +-- +-- ⚠️ À EXÉCUTER dans l'éditeur SQL Supabase : +-- https://supabase.com/dashboard → votre projet → SQL Editor +-- ============================================================================ + +ALTER TABLE public.perturbations + ADD COLUMN IF NOT EXISTS perturbations_arrets JSONB DEFAULT NULL; + +COMMENT ON COLUMN public.perturbations.perturbations_arrets IS + 'Snapshot complet des perturbations par arrêt (état du formulaire). ' + 'Format : [{ + "index": 0, + "gare_nom": "Paris Gare de Lyon", + "heure_arrivee": null, + "heure_depart": "07:30", + "type_perturbation": "retard", + "retard_minutes": 15, + "retard_cumule": 0, + "arret_prolonge_minutes": 0, + "cause_arret": "Incident technique", + "is_nouveau_terminus": false, + "is_nouvelle_origine": false, + "isFirst": true, + "isLast": false, + "manual_retard": true, + "manual_arret_prolonge": false + }, ...]'; + +-- ============================================================================ +-- MIGRATION: Ajout du système de gestion des perturbations +-- Date: 2026-02-06 +-- Description: Ajoute les tables nécessaires pour gérer les perturbations +-- affectant les horaires de trains +-- ============================================================================ + +-- Vérifier que les tables n'existent pas déjà +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'perturbations') THEN + RAISE NOTICE 'La table perturbations existe déjà, migration annulée'; + RETURN; + END IF; +END $$; + +-- Exécuter le script principal +-- supabase_perturbations_tables.sql a déjà été importé précédemment + +-- Vérification post-migration +DO $$ +DECLARE + perturbations_count INTEGER; + modifications_count INTEGER; +BEGIN + -- Vérifier que les tables ont été créées + SELECT COUNT(*) INTO perturbations_count + FROM information_schema.tables + WHERE table_name = 'perturbations'; + + SELECT COUNT(*) INTO modifications_count + FROM information_schema.tables + WHERE table_name = 'perturbations_modifications_parcours'; + + IF perturbations_count = 1 AND modifications_count = 1 THEN + RAISE NOTICE '✓ Migration réussie : Tables créées avec succès'; + ELSE + RAISE EXCEPTION '✗ Échec de la migration : Les tables n''ont pas été créées correctement'; + END IF; + + -- Afficher les colonnes créées + RAISE NOTICE 'Colonnes de la table perturbations:'; + FOR rec IN ( + SELECT column_name, data_type + FROM information_schema.columns + WHERE table_name = 'perturbations' + ORDER BY ordinal_position + ) LOOP + RAISE NOTICE ' - % (%)', rec.column_name, rec.data_type; + END LOOP; + +END $$; + +-- Test d'insertion (rollback automatique) +DO $$ +DECLARE + test_horaire_id UUID; + test_perturbation_id UUID; +BEGIN + -- Récupérer un horaire existant pour le test + SELECT id INTO test_horaire_id FROM horaires LIMIT 1; + + IF test_horaire_id IS NULL THEN + RAISE NOTICE 'Aucun horaire disponible pour le test'; + RETURN; + END IF; + + -- Test d'insertion d'une perturbation + INSERT INTO perturbations ( + horaire_id, + type_perturbation, + duree_retard_minutes, + cause, + cause_details, + actif + ) VALUES ( + test_horaire_id, + 'retard', + 15, + 'incident_technique', + 'Test de migration', + false + ) RETURNING id INTO test_perturbation_id; + + IF test_perturbation_id IS NOT NULL THEN + RAISE NOTICE '✓ Test d''insertion réussi'; + -- Supprimer le test + DELETE FROM perturbations WHERE id = test_perturbation_id; + ELSE + RAISE EXCEPTION '✗ Échec du test d''insertion'; + END IF; + +EXCEPTION + WHEN OTHERS THEN + RAISE NOTICE '✗ Erreur lors du test : %', SQLERRM; +END $$; + +-- Afficher un résumé +SELECT + 'Migration terminée' as statut, + NOW() as date_migration, + (SELECT COUNT(*) FROM perturbations) as nombre_perturbations, + (SELECT COUNT(*) FROM perturbations_modifications_parcours) as nombre_modifications; + +-- ============================================================================ +-- ROLLBACK (à utiliser en cas de problème) +-- ============================================================================ +-- DROP TABLE IF EXISTS perturbations_modifications_parcours CASCADE; +-- DROP TABLE IF EXISTS perturbations CASCADE; +-- ============================================================================ +-- Migration : Ajout du champ "quai" dans horaires_gares_desservies +-- Date : 2026-03-02 +-- ============================================================================ + +-- 1. Ajouter la colonne quai à la table horaires_gares_desservies +ALTER TABLE horaires_gares_desservies + ADD COLUMN IF NOT EXISTS quai VARCHAR(50) DEFAULT NULL; + +COMMENT ON COLUMN horaires_gares_desservies.quai IS 'Numéro ou nom du quai attribué à cet arrêt (ex: "Voie 3", "Quai A")'; + +-- 2. Mettre à jour l'index pour accélérer les recherches par gare_id +CREATE INDEX IF NOT EXISTS idx_hgd_gare_id ON horaires_gares_desservies(gare_id); +CREATE INDEX IF NOT EXISTS idx_hgd_horaire_id ON horaires_gares_desservies(horaire_id); + +-- Note : Le snapshot JSONB horaires.gares_desservies sera mis à jour +-- automatiquement via l'application lors de la sauvegarde des quais. + +-- ============================================================================ +-- MIGRATION : Ajout de la colonne quais_hall (JSONB) dans la table gares +-- Date : 2026-06-10 +-- Description : Stocke l'association entre les halls et les quais au format JSON. +-- Structure : +-- { +-- "hall": { +-- "Nom du hall": ["Voie A", "Voie B"] +-- } +-- } +-- +-- ⚠️ À EXÉCUTER dans l'éditeur SQL Supabase : +-- https://supabase.com/dashboard → votre projet → SQL Editor +-- ============================================================================ + +ALTER TABLE public.gares + ADD COLUMN IF NOT EXISTS quais_hall JSONB DEFAULT '{"hall": {}}'::jsonb; + +COMMENT ON COLUMN public.gares.quais_hall IS + 'Association des quais aux halls sous forme JSON : {"hall": {"Nom du Hall": ["Quai 1", "Quai 2"]}}'; +-- Ajout du champ region_id (slug) à la table menus_regionaux +ALTER TABLE menus_regionaux ADD COLUMN IF NOT EXISTS region_id VARCHAR(64); +-- Index pour accélérer la recherche par région +CREATE INDEX IF NOT EXISTS idx_menus_regionaux_region_id ON menus_regionaux(region_id); +-- Migration: Ajouter la colonne "region" à la table lignes +-- et créer la table "parametres" pour les paramètres du site + +-- 1. Ajout de la colonne region à la table lignes +ALTER TABLE lignes ADD COLUMN IF NOT EXISTS region VARCHAR(100); + +-- 2. Création de la table parametres +CREATE TABLE IF NOT EXISTS parametres ( + id UUID DEFAULT gen_random_uuid() PRIMARY KEY, + cle VARCHAR(100) NOT NULL UNIQUE, + valeur JSONB, + created_at TIMESTAMPTZ DEFAULT now(), + updated_at TIMESTAMPTZ DEFAULT now() +); + +-- Insérer les paramètres par défaut +INSERT INTO parametres (cle, valeur) VALUES + ('general', '{"nom_site": "", "description_site": "", "region": "", "logo_header_url": ""}'), + ('tarifs', '{"billets": [], "abonnements": [], "remises": []}') +ON CONFLICT (cle) DO NOTHING; + +-- 3. Ajout de la colonne quai_defaut à horaires_gares_desservies (pour attribution des quais) +ALTER TABLE horaires_gares_desservies ADD COLUMN IF NOT EXISTS quai_id UUID REFERENCES gare_quais(id); + +-- 4. Politique RLS pour parametres (accès complet pour les utilisateurs authentifiés) +ALTER TABLE parametres ENABLE ROW LEVEL SECURITY; + +CREATE POLICY "parametres_select_all" ON parametres FOR SELECT USING (true); +CREATE POLICY "parametres_insert_all" ON parametres FOR INSERT WITH CHECK (true); +CREATE POLICY "parametres_update_all" ON parametres FOR UPDATE USING (true); +CREATE POLICY "parametres_delete_all" ON parametres FOR DELETE USING (true); + +ALTER TABLE tarifs +ADD COLUMN IF NOT EXISTS details JSONB; + +-- Migration : table menus_regionaux +CREATE TABLE IF NOT EXISTS menus_regionaux ( + id UUID DEFAULT gen_random_uuid() PRIMARY KEY, + nom VARCHAR(200) NOT NULL, + description TEXT, + actif BOOLEAN DEFAULT false, + structure JSONB DEFAULT '[]'::jsonb, + created_at TIMESTAMPTZ DEFAULT now(), + updated_at TIMESTAMPTZ DEFAULT now() +); + +-- RLS +ALTER TABLE menus_regionaux ENABLE ROW LEVEL SECURITY; +CREATE POLICY "menus_select" ON menus_regionaux FOR SELECT USING (true); +CREATE POLICY "menus_insert" ON menus_regionaux FOR INSERT WITH CHECK (true); +CREATE POLICY "menus_update" ON menus_regionaux FOR UPDATE USING (true); +CREATE POLICY "menus_delete" ON menus_regionaux FOR DELETE USING (true); + +-- Tables de messagerie interne (V1) + +CREATE TABLE IF NOT EXISTS public.messagerie_threads ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS public.messagerie_participants ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + thread_id UUID NOT NULL REFERENCES public.messagerie_threads(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES public.utilisateurs(id) ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE(thread_id, user_id) +); + +CREATE TABLE IF NOT EXISTS public.messagerie_messages ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + thread_id UUID NOT NULL REFERENCES public.messagerie_threads(id) ON DELETE CASCADE, + sender_id UUID NOT NULL REFERENCES public.utilisateurs(id) ON DELETE CASCADE, + contenu TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_messagerie_participants_user_id ON public.messagerie_participants(user_id); +CREATE INDEX IF NOT EXISTS idx_messagerie_messages_thread_id ON public.messagerie_messages(thread_id); +CREATE INDEX IF NOT EXISTS idx_messagerie_messages_created_at ON public.messagerie_messages(created_at DESC); + +-- RLS simple: participant du thread uniquement +ALTER TABLE public.messagerie_threads ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.messagerie_participants ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.messagerie_messages ENABLE ROW LEVEL SECURITY; + +DROP POLICY IF EXISTS messagerie_threads_select ON public.messagerie_threads; +CREATE POLICY messagerie_threads_select ON public.messagerie_threads +FOR SELECT TO anon +USING ( + EXISTS ( + SELECT 1 FROM public.messagerie_participants p + WHERE p.thread_id = messagerie_threads.id + ) +); + +DROP POLICY IF EXISTS messagerie_threads_insert ON public.messagerie_threads; +CREATE POLICY messagerie_threads_insert ON public.messagerie_threads +FOR INSERT TO anon +WITH CHECK (true); + +DROP POLICY IF EXISTS messagerie_threads_update ON public.messagerie_threads; +CREATE POLICY messagerie_threads_update ON public.messagerie_threads +FOR UPDATE TO anon +USING ( + EXISTS ( + SELECT 1 FROM public.messagerie_participants p + WHERE p.thread_id = messagerie_threads.id + ) +) +WITH CHECK (true); + +DROP POLICY IF EXISTS messagerie_participants_select ON public.messagerie_participants; +CREATE POLICY messagerie_participants_select ON public.messagerie_participants +FOR SELECT TO anon +USING (true); + +DROP POLICY IF EXISTS messagerie_participants_insert ON public.messagerie_participants; +CREATE POLICY messagerie_participants_insert ON public.messagerie_participants +FOR INSERT TO anon +WITH CHECK (true); + +DROP POLICY IF EXISTS messagerie_messages_select ON public.messagerie_messages; +CREATE POLICY messagerie_messages_select ON public.messagerie_messages +FOR SELECT TO anon +USING ( + EXISTS ( + SELECT 1 FROM public.messagerie_participants p + WHERE p.thread_id = messagerie_messages.thread_id + ) +); + +DROP POLICY IF EXISTS messagerie_messages_insert ON public.messagerie_messages; +CREATE POLICY messagerie_messages_insert ON public.messagerie_messages +FOR INSERT TO anon +WITH CHECK ( + EXISTS ( + SELECT 1 FROM public.messagerie_participants p + WHERE p.thread_id = messagerie_messages.thread_id + AND p.user_id = messagerie_messages.sender_id + ) +); + +-- ============================================================================ +-- MIGRATION : Création du bucket Storage "materiel-roulant" +-- Date : 2026-03-01 +-- Description : Crée le bucket public "materiel-roulant" dans Supabase Storage +-- et configure les policies RLS. +-- +-- ⚠️ IMPORTANT — Authentification custom : +-- Cette application utilise une auth custom (RPC authenticate_user + localStorage) +-- et NON supabase.auth.signInWithPassword(). Par conséquent auth.uid() est +-- toujours NULL côté Supabase. Les policies utilisent donc le rôle "anon" +-- (clé publique anonyme) pour autoriser les opérations Storage depuis le front. +-- +-- ⚠️ À EXÉCUTER UNE SEULE FOIS dans l'éditeur SQL de votre projet Supabase +-- (https://supabase.com/dashboard → votre projet → SQL Editor) +-- ============================================================================ + +-- 1. Créer le bucket s'il n'existe pas encore +INSERT INTO storage.buckets (id, name, public, file_size_limit, allowed_mime_types) +VALUES ( + 'materiel-roulant', + 'materiel-roulant', + true, + 10485760, + ARRAY['image/jpeg','image/png','image/webp','image/gif','image/svg+xml'] +) +ON CONFLICT (id) DO NOTHING; + +-- ============================================================================ +-- 2. Policies RLS sur storage.objects +-- ============================================================================ + +-- Lecture publique +DROP POLICY IF EXISTS "materiel-roulant - lecture publique" ON storage.objects; +CREATE POLICY "materiel-roulant - lecture publique" +ON storage.objects FOR SELECT +USING (bucket_id = 'materiel-roulant'); + +-- Upload (anon car auth custom — auth.uid() toujours NULL) +DROP POLICY IF EXISTS "materiel-roulant - upload authentifié" ON storage.objects; +CREATE POLICY "materiel-roulant - upload authentifié" +ON storage.objects FOR INSERT +WITH CHECK (bucket_id = 'materiel-roulant'); + +-- Mise à jour +DROP POLICY IF EXISTS "materiel-roulant - update authentifié" ON storage.objects; +CREATE POLICY "materiel-roulant - update authentifié" +ON storage.objects FOR UPDATE +USING (bucket_id = 'materiel-roulant'); + +-- Suppression +DROP POLICY IF EXISTS "materiel-roulant - delete authentifié" ON storage.objects; +CREATE POLICY "materiel-roulant - delete authentifié" +ON storage.objects FOR DELETE +USING (bucket_id = 'materiel-roulant'); + +-- Création de la table "tarifs" pour gérer les offres de billets, abonnements, remises par région +CREATE TABLE IF NOT EXISTS tarifs ( + id SERIAL PRIMARY KEY, + nom VARCHAR(255) NOT NULL, + type VARCHAR(50) NOT NULL, -- billet, abonnement, remise + description TEXT, + prix_minimum NUMERIC(10,2), + prix_par_km NUMERIC(10,3), + age_maximum INTEGER, + regions TEXT[] NOT NULL, -- tableau d'identifiants de régions + date_debut DATE, + date_fin DATE, + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() +); + +-- Index pour recherche rapide par région +CREATE INDEX IF NOT EXISTS idx_tarifs_regions ON tarifs USING GIN (regions); + +-- v2: add trigger to keep updated_at in sync +CREATE OR REPLACE FUNCTION touch_updated_at() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = NOW(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS trg_touch_tarifs_updated_at ON tarifs; +CREATE TRIGGER trg_touch_tarifs_updated_at +BEFORE UPDATE ON tarifs +FOR EACH ROW +EXECUTE PROCEDURE touch_updated_at(); + + +-- ============================================================================ +-- CRÉATION DES TABLES POUR LA GESTION DES TRAVAUX +-- ============================================================================ + +-- Table principale des travaux +CREATE TABLE IF NOT EXISTS travaux ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + titre TEXT NOT NULL, + description TEXT, + date_debut DATE NOT NULL, + date_fin DATE NOT NULL, + jours_travaux TEXT[] NOT NULL, -- ['lundi', 'mardi', 'mercredi', 'jeudi', 'vendredi', 'samedi', 'dimanche'] + heure_debut TIME NOT NULL, + heure_fin TIME NOT NULL, + actif BOOLEAN DEFAULT true, + created_at TIMESTAMP WITH TIME ZONE DEFAULT now(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT now(), + deleted_at TIMESTAMP WITH TIME ZONE, + + CONSTRAINT valid_dates CHECK (date_fin >= date_debut), + CONSTRAINT valid_hours CHECK (heure_fin > heure_debut) +); + +-- Table de liaison entre travaux et sillons de substitution +CREATE TABLE IF NOT EXISTS travaux_substitutions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + travaux_id UUID NOT NULL REFERENCES travaux(id) ON DELETE CASCADE, + horaire_id UUID NOT NULL REFERENCES horaires(id) ON DELETE CASCADE, + ordre INTEGER DEFAULT 0, -- Ordre d'affichage des substitutions + created_at TIMESTAMP WITH TIME ZONE DEFAULT now(), + + UNIQUE(travaux_id, horaire_id) +); + +-- Index pour améliorer les performances +CREATE INDEX IF NOT EXISTS idx_travaux_dates ON travaux(date_debut, date_fin); +CREATE INDEX IF NOT EXISTS idx_travaux_actif ON travaux(actif); +CREATE INDEX IF NOT EXISTS idx_travaux_deleted ON travaux(deleted_at); +CREATE INDEX IF NOT EXISTS idx_travaux_substitutions_travaux ON travaux_substitutions(travaux_id); +CREATE INDEX IF NOT EXISTS idx_travaux_substitutions_horaire ON travaux_substitutions(horaire_id); + +-- Fonction pour mettre à jour updated_at automatiquement +CREATE OR REPLACE FUNCTION update_travaux_updated_at() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = now(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +-- Trigger pour mettre à jour updated_at +DROP TRIGGER IF EXISTS trigger_update_travaux_updated_at ON travaux; +CREATE TRIGGER trigger_update_travaux_updated_at + BEFORE UPDATE ON travaux + FOR EACH ROW + EXECUTE FUNCTION update_travaux_updated_at(); + +-- Commentaires sur les tables +COMMENT ON TABLE travaux IS 'Table principale pour la gestion des travaux sur les lignes'; +COMMENT ON TABLE travaux_substitutions IS 'Table de liaison entre travaux et horaires de substitution'; + +COMMENT ON COLUMN travaux.jours_travaux IS 'Jours de la semaine où les travaux ont lieu'; +COMMENT ON COLUMN travaux.actif IS 'Indique si les travaux sont actifs'; +COMMENT ON COLUMN travaux.deleted_at IS 'Date de suppression (soft delete)'; +COMMENT ON COLUMN travaux_substitutions.ordre IS 'Ordre d''affichage des substitutions'; + +-- ============================================================================ +-- DONNÉES DE TEST (optionnel) +-- ============================================================================ + +-- Exemple de travaux +INSERT INTO travaux (titre, description, date_debut, date_fin, jours_travaux, heure_debut, heure_fin, actif) +VALUES + ('Travaux ligne Paris-Lyon', 'Rénovation des voies', '2026-03-01', '2026-03-31', ARRAY['lundi', 'mardi', 'mercredi', 'jeudi', 'vendredi'], '09:00', '17:00', true), + ('Maintenance gare de Lyon', 'Travaux de maintenance planifiés', '2026-04-15', '2026-04-20', ARRAY['samedi', 'dimanche'], '06:00', '22:00', true) +ON CONFLICT DO NOTHING; + +-- Afficher le résultat +SELECT + 'Tables travaux créées avec succès' as statut, + (SELECT COUNT(*) FROM travaux) as nombre_travaux, + (SELECT COUNT(*) FROM travaux_substitutions) as nombre_substitutions; + +-- ============================================================================ +-- CORRECTIF : Policies RLS du bucket "banque-annonces" et des tables SQL +-- Date : 2026-07-02 +-- +-- Problème : L'application utilise la clé anon et une authentification custom. Les +-- policies de banque_mots et de storage.objects qui exigent un utilisateur +-- authentifié (TO authenticated) bloquent l'INSERT/UPDATE/DELETE et provoquent +-- l'erreur "new row violates row-level security policy". +-- +-- Solution : Recréer des policies pour permettre SELECT, INSERT, UPDATE, DELETE +-- sur le bucket 'banque-annonces' et sur les tables de la base de données. +-- +-- ⚠️ À EXÉCUTER dans l'éditeur SQL Supabase : +-- https://app.supabase.com → votre projet → SQL Editor +-- ============================================================================ + +-- 1. Réglage des politiques RLS pour les tables de base de données +-- ============================================================================ + +-- Table: banque_mots +DROP POLICY IF EXISTS "Authenticated users can read banque_mots" ON public.banque_mots; +DROP POLICY IF EXISTS "Authenticated users can insert banque_mots" ON public.banque_mots; +DROP POLICY IF EXISTS "Authenticated users can update banque_mots" ON public.banque_mots; + +CREATE POLICY "Public read banque_mots" ON public.banque_mots FOR SELECT USING (deleted_at IS NULL); +CREATE POLICY "Public insert banque_mots" ON public.banque_mots FOR INSERT WITH CHECK (true); +CREATE POLICY "Public update banque_mots" ON public.banque_mots FOR UPDATE USING (true); +CREATE POLICY "Public delete banque_mots" ON public.banque_mots FOR DELETE USING (true); + +-- Table: annonces_templates +DROP POLICY IF EXISTS "Authenticated users can read templates" ON public.annonces_templates; +DROP POLICY IF EXISTS "Authenticated users can insert templates" ON public.annonces_templates; +DROP POLICY IF EXISTS "Authenticated users can update templates" ON public.annonces_templates; + +CREATE POLICY "Public read templates" ON public.annonces_templates FOR SELECT USING (deleted_at IS NULL); +CREATE POLICY "Public insert templates" ON public.annonces_templates FOR INSERT WITH CHECK (true); +CREATE POLICY "Public update templates" ON public.annonces_templates FOR UPDATE USING (true); +CREATE POLICY "Public delete templates" ON public.annonces_templates FOR DELETE USING (true); + +-- Table: annonces_enregistrees +DROP POLICY IF EXISTS "Authenticated users can read annonces" ON public.annonces_enregistrees; +DROP POLICY IF EXISTS "Authenticated users can insert annonces" ON public.annonces_enregistrees; +DROP POLICY IF EXISTS "Authenticated users can update annonces" ON public.annonces_enregistrees; + +CREATE POLICY "Public read annonces" ON public.annonces_enregistrees FOR SELECT USING (deleted_at IS NULL); +CREATE POLICY "Public insert annonces" ON public.annonces_enregistrees FOR INSERT WITH CHECK (true); +CREATE POLICY "Public update annonces" ON public.annonces_enregistrees FOR UPDATE USING (true); +CREATE POLICY "Public delete annonces" ON public.annonces_enregistrees FOR DELETE USING (true); + + +-- 2. Réglage des politiques RLS du Bucket Storage "banque-annonces" +-- ============================================================================ + +-- Supprimer les anciennes politiques storage si elles existent +DROP POLICY IF EXISTS "Public read access on banque-annonces" ON storage.objects; +DROP POLICY IF EXISTS "Authenticated users can upload to banque-annonces" ON storage.objects; +DROP POLICY IF EXISTS "Authenticated users can delete from banque-annonces" ON storage.objects; +DROP POLICY IF EXISTS "Public upload access on banque-annonces" ON storage.objects; +DROP POLICY IF EXISTS "Public update access on banque-annonces" ON storage.objects; +DROP POLICY IF EXISTS "Public delete access on banque-annonces" ON storage.objects; + +-- Lecture publique pour tous +CREATE POLICY "Public read access on banque-annonces" +ON storage.objects FOR SELECT +USING (bucket_id = 'banque-annonces'); + +-- Insertion publique (Upload) +CREATE POLICY "Public upload access on banque-annonces" +ON storage.objects FOR INSERT +WITH CHECK (bucket_id = 'banque-annonces'); + +-- Mise à jour publique (Upsert) +CREATE POLICY "Public update access on banque-annonces" +ON storage.objects FOR UPDATE +USING (bucket_id = 'banque-annonces'); + +-- Suppression publique (Delete) +CREATE POLICY "Public delete access on banque-annonces" +ON storage.objects FOR DELETE +USING (bucket_id = 'banque-annonces'); +-- ============================================================================ +-- CORRECTIF : Policies RLS du bucket "fiches-horaires" +-- Date : 2026-03-01 +-- +-- Problème : L'application utilise la clé anon et une authentification custom. Les +-- policies de storage.objects qui exigent auth.uid() bloquent l'INSERT +-- et provoquent l'erreur "new row violates row-level security policy". +-- +-- Solution : Recréer des policies pour permettre l'INSERT/UPDATE/DELETE/SELECT +-- sur le bucket 'fiches-horaires' en se basant sur bucket_id seulement. +-- +-- ⚠️ À EXÉCUTER dans l'éditeur SQL Supabase : +-- https://app.supabase.com → votre projet → SQL Editor +-- ============================================================================ + +-- Supprimer d'abord les policies existantes (si elles existent) +DROP POLICY IF EXISTS "fiches-horaires - lecture publique" ON storage.objects; +DROP POLICY IF EXISTS "fiches-horaires - upload" ON storage.objects; +DROP POLICY IF EXISTS "fiches-horaires - update" ON storage.objects; +DROP POLICY IF EXISTS "fiches-horaires - delete" ON storage.objects; + +-- Créer des policies permissives basées sur le bucket_id +CREATE POLICY "fiches-horaires - lecture publique" +ON storage.objects FOR SELECT +USING (bucket_id = 'fiches-horaires'); + +CREATE POLICY "fiches-horaires - upload" +ON storage.objects FOR INSERT +WITH CHECK (bucket_id = 'fiches-horaires'); + +CREATE POLICY "fiches-horaires - update" +ON storage.objects FOR UPDATE +USING (bucket_id = 'fiches-horaires'); + +CREATE POLICY "fiches-horaires - delete" +ON storage.objects FOR DELETE +USING (bucket_id = 'fiches-horaires'); + +-- Note : Si vous souhaitez restreindre les uploads aux utilisateurs authentifiés, +-- remplacez la clause WITH CHECK (bucket_id = 'fiches-horaires') par +-- WITH CHECK (bucket_id = 'fiches-horaires' AND auth.uid() IS NOT NULL) +-- et assurez-vous que vos utilisateurs sont bien authentifiés côté front. + +-- ============================================================================ +-- CORRECTIF : Policies RLS du bucket "materiel-roulant" +-- Date : 2026-03-01 +-- +-- Problème : L'application utilise une authentification custom (RPC authenticate_user +-- + localStorage). supabase.auth.signIn() n'est jamais appelé, donc +-- auth.uid() est toujours NULL → la policy WITH CHECK auth.uid() IS NOT NULL +-- bloque tous les uploads. +-- +-- Solution : Policies ouvertes sur le bucket (accès par clé anon publique). +-- Le bucket est déjà créé — ce script corrige uniquement les policies. +-- +-- ⚠️ À EXÉCUTER dans l'éditeur SQL Supabase : +-- https://supabase.com/dashboard → votre projet → SQL Editor +-- ============================================================================ + +-- Supprimer toutes les policies existantes du bucket +DROP POLICY IF EXISTS "materiel-roulant - lecture publique" ON storage.objects; +DROP POLICY IF EXISTS "materiel-roulant - upload authentifié" ON storage.objects; +DROP POLICY IF EXISTS "materiel-roulant - update authentifié" ON storage.objects; +DROP POLICY IF EXISTS "materiel-roulant - delete authentifié" ON storage.objects; + +-- Recréer les policies sans condition auth.uid() +-- (l'app utilise la clé anon, auth.uid() est toujours NULL) + +CREATE POLICY "materiel-roulant - lecture publique" +ON storage.objects FOR SELECT +USING (bucket_id = 'materiel-roulant'); + +CREATE POLICY "materiel-roulant - upload authentifié" +ON storage.objects FOR INSERT +WITH CHECK (bucket_id = 'materiel-roulant'); + +CREATE POLICY "materiel-roulant - update authentifié" +ON storage.objects FOR UPDATE +USING (bucket_id = 'materiel-roulant'); + +CREATE POLICY "materiel-roulant - delete authentifié" +ON storage.objects FOR DELETE +USING (bucket_id = 'materiel-roulant'); + +-- Migration: repair foreign key between horaires_gares_desservies.horaire_id and horaires.id +-- This script will: +-- 1) check the column types of horaires.id and horaires_gares_desservies.horaire_id +-- 2) if types match and FK doesn't exist, create the FK constraint +-- 3) otherwise, print a NOTICE explaining the mismatch for manual resolution + +DO $$ +DECLARE + t_horaire TEXT; + t_horaire_fk TEXT; + cnt INT; +BEGIN + -- ensure target tables exist + IF NOT EXISTS (SELECT 1 FROM pg_class WHERE relname = 'horaires' AND relkind = 'r') THEN + RAISE NOTICE 'Table public.horaires does not exist in this database/schema.'; + RETURN; + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_class WHERE relname = 'horaires_gares_desservies' AND relkind = 'r') THEN + RAISE NOTICE 'Table public.horaires_gares_desservies does not exist in this database/schema.'; + RETURN; + END IF; + + -- get formatted types + SELECT pg_catalog.format_type(a.atttypid, a.atttypmod) + INTO t_horaire + FROM pg_attribute a + WHERE a.attrelid = 'public.horaires'::regclass AND a.attname = 'id'; + + SELECT pg_catalog.format_type(a.atttypid, a.atttypmod) + INTO t_horaire_fk + FROM pg_attribute a + WHERE a.attrelid = 'public.horaires_gares_desservies'::regclass AND a.attname = 'horaire_id'; + + RAISE NOTICE 'Detected types -> horaires.id: %, horaires_gares_desservies.horaire_id: %', t_horaire, t_horaire_fk; + + -- check existing FK from horaires_gares_desservies to horaires + SELECT count(*) INTO cnt + FROM pg_constraint c + WHERE c.conrelid = 'public.horaires_gares_desservies'::regclass + AND c.confrelid = 'public.horaires'::regclass + AND c.contype = 'f'; + + IF cnt > 0 THEN + RAISE NOTICE 'A foreign-key constraint from horaires_gares_desservies to horaires already exists (count=%). No action taken.', cnt; + RETURN; + END IF; + + -- If types match, create the FK + IF t_horaire IS NOT NULL AND t_horaire_fk IS NOT NULL AND t_horaire = t_horaire_fk THEN + EXECUTE 'ALTER TABLE public.horaires_gares_desservies + ADD CONSTRAINT fk_horaires_gares_horaire_id + FOREIGN KEY (horaire_id) + REFERENCES public.horaires(id) + ON DELETE CASCADE'; + RAISE NOTICE 'Foreign key constraint fk_horaires_gares_horaire_id created successfully.'; + ELSE + RAISE NOTICE 'Column types do not match or could not be determined. Manual intervention required.'; + RAISE NOTICE 'Detected types -> horaires.id: %, horaires_gares_desservies.horaire_id: %', t_horaire, t_horaire_fk; + RAISE NOTICE 'If both should be UUID, consider converting [or recreating] the columns and then re-run this script.'; + RAISE NOTICE 'If both should be BIGINT, consider altering types to bigint. Do not run automatic conversion without backup.'; + END IF; +END $$; +-- ============================================================================ +-- DONNÉES DE TEST: Perturbations +-- Description: Jeux de données pour tester le système de perturbations +-- ============================================================================ + +-- Note: Ces données sont à insérer APRÈS avoir des horaires existants dans la base + +-- Exemple 1: Retard sur un train +-- Remplacer 'HORAIRE_ID_1' par un véritable ID d'horaire existant +INSERT INTO perturbations ( + horaire_id, + type_perturbation, + duree_retard_minutes, + cause, + cause_details, + date_debut, + date_fin, + actif, + notes +) VALUES ( + (SELECT id FROM horaires LIMIT 1), -- Premier horaire de la base + 'retard', + 25, + 'incident_technique', + 'Panne de signalisation à Paris-Montparnasse', + NOW(), + NOW() + INTERVAL '2 hours', + true, + 'Retard estimé de 25 minutes sur l''ensemble du parcours' +); + +-- Exemple 2: Suppression de train avec substitution +INSERT INTO perturbations ( + horaire_id, + type_perturbation, + duree_retard_minutes, + cause, + cause_details, + sillon_substitution_id, + date_debut, + date_fin, + actif, + notes +) VALUES ( + (SELECT id FROM horaires LIMIT 1 OFFSET 1), -- Deuxième horaire + 'suppression', + NULL, + 'mouvement_social', + 'Grève nationale du personnel roulant', + (SELECT id FROM horaires LIMIT 1 OFFSET 2), -- Train de substitution + NOW(), + NOW() + INTERVAL '1 day', + true, + 'Train supprimé - Report sur le train suivant disponible' +); + +-- Exemple 3: Modification de parcours +DO $$ +DECLARE + v_perturbation_id UUID; + v_horaire_id UUID; +BEGIN + -- Récupérer un horaire avec des gares desservies + SELECT h.id INTO v_horaire_id + FROM horaires h + WHERE EXISTS ( + SELECT 1 FROM horaires_gares_desservies hgd + WHERE hgd.horaire_id = h.id + ) + LIMIT 1; + + IF v_horaire_id IS NULL THEN + RAISE NOTICE 'Aucun horaire avec gares desservies trouvé, test ignoré'; + RETURN; + END IF; + + -- Créer la perturbation + INSERT INTO perturbations ( + horaire_id, + type_perturbation, + duree_retard_minutes, + cause, + cause_details, + date_debut, + actif, + notes + ) VALUES ( + v_horaire_id, + 'modification_parcours', + NULL, + 'travaux', + 'Travaux de maintenance sur la voie entre Lyon et Valence', + NOW(), + true, + 'Modification du parcours : certaines gares ne seront pas desservies' + ) RETURNING id INTO v_perturbation_id; + + -- Ajouter des modifications de parcours + -- Supprimer une gare du parcours + INSERT INTO perturbations_modifications_parcours ( + perturbation_id, + type_modification, + gare_id, + gare_nom, + ordre + ) SELECT + v_perturbation_id, + 'gare_supprimee', + gare_id, + gare_nom, + ordre + FROM horaires_gares_desservies + WHERE horaire_id = v_horaire_id + LIMIT 1; + + RAISE NOTICE 'Perturbation de modification de parcours créée avec succès'; +END $$; + +-- Exemple 4: Retard causé par les conditions météorologiques +INSERT INTO perturbations ( + horaire_id, + type_perturbation, + duree_retard_minutes, + cause, + cause_details, + date_debut, + date_fin, + actif, + notes +) VALUES ( + (SELECT id FROM horaires LIMIT 1 OFFSET 3), + 'retard', + 45, + 'conditions_meteorologiques', + 'Fortes chutes de neige en Auvergne-Rhône-Alpes', + NOW(), + NOW() + INTERVAL '4 hours', + true, + 'Circulation ralentie sur l''ensemble des lignes' +); + +-- Exemple 5: Perturbation inactive (historique) +INSERT INTO perturbations ( + horaire_id, + type_perturbation, + duree_retard_minutes, + cause, + cause_details, + date_debut, + date_fin, + actif, + notes +) VALUES ( + (SELECT id FROM horaires LIMIT 1 OFFSET 4), + 'retard', + 10, + 'affluence', + 'Affluence exceptionnelle lors du départ', + NOW() - INTERVAL '1 day', + NOW() - INTERVAL '1 day' + INTERVAL '30 minutes', + false, + 'Perturbation terminée - Retard résorbé' +); + +-- Afficher un résumé des perturbations de test créées +SELECT + 'Données de test créées' as statut, + COUNT(*) as nombre_perturbations_test, + COUNT(*) FILTER (WHERE actif = true) as perturbations_actives, + COUNT(*) FILTER (WHERE actif = false) as perturbations_inactives +FROM perturbations +WHERE notes LIKE '%test%' OR notes LIKE '%Test%'; + +-- Afficher les perturbations créées +SELECT + p.id, + p.type_perturbation, + p.duree_retard_minutes, + p.cause, + p.actif, + h.numero_train, + h.gare_depart_nom, + h.gare_arrivee_nom +FROM perturbations p +JOIN horaires h ON p.horaire_id = h.id +ORDER BY p.created_at DESC +LIMIT 10; + +-- ============================================================================ +-- NETTOYAGE DES DONNÉES DE TEST +-- ============================================================================ +-- Décommenter pour supprimer toutes les données de test +/* +DELETE FROM perturbations_modifications_parcours +WHERE perturbation_id IN ( + SELECT id FROM perturbations + WHERE notes LIKE '%test%' OR notes LIKE '%Test%' +); + +DELETE FROM perturbations +WHERE notes LIKE '%test%' OR notes LIKE '%Test%'; + +SELECT 'Données de test supprimées' as statut; +*/ +-- MIGRATION : Ajout de la colonne quais_hall (JSONB) dans la table gares + +ALTER TABLE public.gares + ADD COLUMN IF NOT EXISTS quais_hall JSONB DEFAULT '{"hall": {}}'::jsonb; + +COMMENT ON COLUMN public.gares.quais_hall IS + 'Association des quais aux halls sous forme JSON : {"hall": {"Nom du Hall": ["Quai 1", "Quai 2"]}}'; +-- Migration: 20260611000000_secure_all_tables.sql +-- Description: Enable RLS on all public tables and restrict INSERT/UPDATE/DELETE to authenticated users. + +DO $$ +DECLARE + r RECORD; +BEGIN + FOR r IN SELECT tablename FROM pg_tables WHERE schemaname = 'public' + LOOP + -- Enable RLS + EXECUTE 'ALTER TABLE public.' || quote_ident(r.tablename) || ' ENABLE ROW LEVEL SECURITY;'; + + -- Clean up existing potentially insecure policies to be sure + EXECUTE 'DROP POLICY IF EXISTS "Public read access" ON public.' || quote_ident(r.tablename) || ';'; + EXECUTE 'DROP POLICY IF EXISTS "Enable insert for authenticated users only" ON public.' || quote_ident(r.tablename) || ';'; + EXECUTE 'DROP POLICY IF EXISTS "Enable update for authenticated users only" ON public.' || quote_ident(r.tablename) || ';'; + EXECUTE 'DROP POLICY IF EXISTS "Enable delete for authenticated users only" ON public.' || quote_ident(r.tablename) || ';'; + + -- Read policy (SELECT): Allow public read + EXECUTE 'CREATE POLICY "Public read access" ON public.' || quote_ident(r.tablename) || ' FOR SELECT USING (true);'; + + -- Insert policy: Allow anon (app uses custom auth, no Supabase Auth) + EXECUTE 'CREATE POLICY "Public insert access" ON public.' || quote_ident(r.tablename) || ' FOR INSERT WITH CHECK (true);'; + + -- Update policy: Allow anon + EXECUTE 'CREATE POLICY "Public update access" ON public.' || quote_ident(r.tablename) || ' FOR UPDATE USING (true);'; + + -- Delete policy: Prevent hard deletion for everyone (prevents third party deletion) + EXECUTE 'CREATE POLICY "Prevent hard deletion" ON public.' || quote_ident(r.tablename) || ' FOR DELETE USING (false);'; + END LOOP; +END $$; +-- Migration: 20260611000001_fix_secure_all_tables.sql +-- Description: Fix RLS policies to allow public insert/update since app uses custom auth, but strictly prevent delete. + +DO $$ +DECLARE + r RECORD; +BEGIN + FOR r IN SELECT tablename FROM pg_tables WHERE schemaname = 'public' + LOOP + -- Clean up the overly restrictive policies from previous migration + EXECUTE 'DROP POLICY IF EXISTS "Enable insert for authenticated users only" ON public.' || quote_ident(r.tablename) || ';'; + EXECUTE 'DROP POLICY IF EXISTS "Enable update for authenticated users only" ON public.' || quote_ident(r.tablename) || ';'; + EXECUTE 'DROP POLICY IF EXISTS "Enable delete for authenticated users only" ON public.' || quote_ident(r.tablename) || ';'; + + -- Insert policy: Allow anon (app uses custom auth, no Supabase Auth) + EXECUTE 'DROP POLICY IF EXISTS "Public insert access" ON public.' || quote_ident(r.tablename) || ';'; + EXECUTE 'CREATE POLICY "Public insert access" ON public.' || quote_ident(r.tablename) || ' FOR INSERT WITH CHECK (true);'; + + -- Update policy: Allow anon + EXECUTE 'DROP POLICY IF EXISTS "Public update access" ON public.' || quote_ident(r.tablename) || ';'; + EXECUTE 'CREATE POLICY "Public update access" ON public.' || quote_ident(r.tablename) || ' FOR UPDATE USING (true);'; + + -- Delete policy: Prevent hard deletion for everyone (prevents third party deletion) + EXECUTE 'DROP POLICY IF EXISTS "Prevent hard deletion" ON public.' || quote_ident(r.tablename) || ';'; + EXECUTE 'CREATE POLICY "Prevent hard deletion" ON public.' || quote_ident(r.tablename) || ' FOR DELETE USING (false);'; + END LOOP; +END $$; +-- ============================================================================ +-- MIGRATION : types_train — ajout de la colonne "logo_color_url" +-- +-- Ajoute une colonne "logo_color_url" pour stocker l'URL du logo en couleur. +-- +-- À exécuter dans : Supabase Dashboard > SQL Editor +-- ============================================================================ + +ALTER TABLE "public"."types_train" + ADD COLUMN IF NOT EXISTS "logo_color_url" text; +-- ============================================================================ +-- MIGRATION : info_gares — gare_ids uuid[] + ligne_ids uuid[] +-- +-- Remplace les colonnes scalaires gare_id / ligne_id par des colonnes tableau +-- afin d'autoriser plusieurs gares et plusieurs lignes par info. +-- +-- À exécuter dans : Supabase Dashboard > SQL Editor +-- ============================================================================ + +-- 1. Ajouter les nouvelles colonnes tableaux (nullables, par défaut vide) +ALTER TABLE "public"."info_gares" + ADD COLUMN IF NOT EXISTS "gare_ids" uuid[] NOT NULL DEFAULT '{}', + ADD COLUMN IF NOT EXISTS "ligne_ids" uuid[] NOT NULL DEFAULT '{}'; + +-- 2. Migrer les données existantes depuis les colonnes scalaires +UPDATE "public"."info_gares" +SET + gare_ids = CASE WHEN gare_id IS NOT NULL THEN ARRAY[gare_id] ELSE '{}' END, + ligne_ids = CASE WHEN ligne_id IS NOT NULL THEN ARRAY[ligne_id] ELSE '{}' END +WHERE deleted_at IS NULL; + +-- 3. Supprimer les anciennes colonnes scalaires +-- (décommentez les lignes ci-dessous après avoir vérifié la migration) +-- ALTER TABLE "public"."info_gares" DROP COLUMN IF EXISTS "gare_id"; +-- ALTER TABLE "public"."info_gares" DROP COLUMN IF EXISTS "ligne_id"; + +-- NOTE : les colonnes gare_id / ligne_id sont conservées pour l'instant. +-- Après validation, décommentez le bloc DROP COLUMN ci-dessus et ré-exécutez. + +-- 4. Index GIN pour de meilleures performances sur les requêtes @> (contient) +CREATE INDEX IF NOT EXISTS idx_info_gares_gare_ids ON "public"."info_gares" USING GIN (gare_ids); +CREATE INDEX IF NOT EXISTS idx_info_gares_ligne_ids ON "public"."info_gares" USING GIN (ligne_ids); +-- ============================================================================ +-- MIGRATION : info_gares — ajout de la colonne "type" +-- +-- Ajoute une colonne "type" pour catégoriser les infos gares. +-- Les valeurs possibles sont gérées côté applicatif : +-- 'Information', 'Avertissement', 'Alerte', 'Urgence', 'Alerte Méteo' +-- +-- À exécuter dans : Supabase Dashboard > SQL Editor +-- ============================================================================ + +ALTER TABLE "public"."info_gares" + ADD COLUMN IF NOT EXISTS "type" text NOT NULL DEFAULT 'Information'; +-- ============================================================================ +-- FIX RLS : table info_gares +-- +-- La table info_gares a RLS activé mais aucune policy n'est définie, +-- ce qui bloque toutes les opérations (INSERT, UPDATE, DELETE, SELECT). +-- +-- Ce script suit le même pattern que les autres tables du projet +-- (menus_regionaux, parametres) : accès ouvert à tous les rôles +-- utilisant la clé anon/service (gestion côté application). +-- +-- À exécuter dans : Supabase Dashboard > SQL Editor +-- ============================================================================ + +-- 1. S'assurer que RLS est bien activé sur la table +ALTER TABLE "public"."info_gares" ENABLE ROW LEVEL SECURITY; + +-- 2. Supprimer les éventuelles policies existantes (idempotent) +DROP POLICY IF EXISTS "info_gares_select" ON "public"."info_gares"; +DROP POLICY IF EXISTS "info_gares_insert" ON "public"."info_gares"; +DROP POLICY IF EXISTS "info_gares_update" ON "public"."info_gares"; +DROP POLICY IF EXISTS "info_gares_delete" ON "public"."info_gares"; + +-- 3. Créer les 4 policies CRUD (lecture, insertion, modification, suppression) +CREATE POLICY "info_gares_select" + ON "public"."info_gares" + FOR SELECT + USING (true); + +CREATE POLICY "info_gares_insert" + ON "public"."info_gares" + FOR INSERT + WITH CHECK (true); + +CREATE POLICY "info_gares_update" + ON "public"."info_gares" + FOR UPDATE + USING (true); + +CREATE POLICY "info_gares_delete" + ON "public"."info_gares" + FOR DELETE + USING (true); diff --git a/src/App.css b/src/App.css index 4a5ba6d..506e4f5 100644 --- a/src/App.css +++ b/src/App.css @@ -1090,3 +1090,26 @@ wcs-textarea::part(textarea) { } } +/* ============================================= + GLOBAL RESPONSIVE TABLES (PWA / MOBILES) + ============================================= */ +@media (max-width: 900px) { + /* Rend tous les tableaux défilables horizontalement sur mobile/tablette */ + table, + .wcs-table, + .import-table, + .as-table, + .attribution-depart-arrivee-table { + display: block !important; + width: 100% !important; + overflow-x: auto !important; + -webkit-overflow-scrolling: touch; /* Pour un scroll fluide sur iOS */ + white-space: nowrap; + } + + /* Assure que les cellules gardent leur espacement sans s'écraser */ + th, td { + white-space: nowrap; + max-width: none !important; + } +} diff --git a/src/api/gares/index.js b/src/api/gares/index.js index 95d9303..6029598 100644 --- a/src/api/gares/index.js +++ b/src/api/gares/index.js @@ -21,7 +21,7 @@ import { reassignGareInHoraires } from '../horaires'; function sanitizeGareData(data) { return { nom: data.nom ? String(data.nom).trim() : '', - type: data.type === 'interurbaine' ? 'interurbaine' : 'ville', + type: String(data.type || '').trim().toLowerCase() === 'interurbaine' ? 'interurbaine' : 'ville', services: Array.isArray(data.services) ? data.services.filter(Boolean) : [], informations: data.informations && typeof data.informations === 'object' ? data.informations diff --git a/src/pages/gares/Gares.js b/src/pages/gares/Gares.js index 4f3fe4d..7c6752d 100644 --- a/src/pages/gares/Gares.js +++ b/src/pages/gares/Gares.js @@ -924,15 +924,17 @@ export default function Gares() {