prepare( 'SELECT 1 FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ?' ); $st->execute([$table, $col]); if (!$st->fetchColumn()) { $pdo->exec("ALTER TABLE `$table` ADD COLUMN $ddl"); say(" + colonne $table.$col ajoutée."); } } try { // 1. Création de la base (connexion sans schéma). $root = new PDO( 'mysql:host=' . DB_HOST . ';port=' . DB_PORT . ';charset=utf8mb4', DB_USER, DB_PASS, [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION] ); $root->exec('CREATE DATABASE IF NOT EXISTS `' . DB_NAME . '` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci'); say('✔ Base « ' . DB_NAME . ' » prête.'); $pdo = db(); // 2. Établissements. $pdo->exec( 'CREATE TABLE IF NOT EXISTS etablissements ( id VARCHAR(12) NOT NULL PRIMARY KEY, nom VARCHAR(255), type VARCHAR(80), stat VARCHAR(40), reg VARCHAR(120), aca VARCHAR(120), dep VARCHAR(120), depc VARCHAR(10), com VARCHAR(160), cp VARCHAR(10), adr VARCHAR(255), tel VARCHAR(40), mail VARCHAR(160), web VARCHAR(255), etat VARCHAR(20), circ VARCHAR(255), lat DECIMAL(9,5), lon DECIMAL(9,5), rest TINYINT NOT NULL DEFAULT 0, heb TINYINT NOT NULL DEFAULT 0, ulis TINYINT NOT NULL DEFAULT 0, segp TINYINT NOT NULL DEFAULT 0, appr TINYINT NOT NULL DEFAULT 0, INDEX idx_type (type), INDEX idx_reg (reg), INDEX idx_aca (aca), INDEX idx_dep (dep), INDEX idx_comdep (com, depc), INDEX idx_etat (etat) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4' ); // Anciennes tables de la v1 (remplacées par « items »). $pdo->exec('DROP TABLE IF EXISTS documents'); $pdo->exec('DROP TABLE IF EXISTS photos'); // 3. Utilisateurs (admins) et périmètres. $pdo->exec( "CREATE TABLE IF NOT EXISTS users ( id INT NOT NULL AUTO_INCREMENT PRIMARY KEY, username VARCHAR(80) NOT NULL UNIQUE, password_hash VARCHAR(255) NOT NULL, role ENUM('super','perimeter','member') NOT NULL DEFAULT 'perimeter', email VARCHAR(160) NULL, must_change TINYINT NOT NULL DEFAULT 0, failed_attempts INT NOT NULL DEFAULT 0, locked_until DATETIME NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4" ); // Migration des bases existantes : rôle « member » + colonnes de politique de mot de passe. $pdo->exec("ALTER TABLE users MODIFY role ENUM('super','perimeter','member') NOT NULL DEFAULT 'perimeter'"); add_col_if_missing($pdo, 'users', 'email', 'email VARCHAR(160) NULL'); add_col_if_missing($pdo, 'users', 'must_change', 'must_change TINYINT NOT NULL DEFAULT 0'); add_col_if_missing($pdo, 'users', 'failed_attempts', 'failed_attempts INT NOT NULL DEFAULT 0'); add_col_if_missing($pdo, 'users', 'locked_until', 'locked_until DATETIME NULL'); // Jetons de réinitialisation de mot de passe (lien à usage unique). $pdo->exec( 'CREATE TABLE IF NOT EXISTS password_resets ( id INT NOT NULL AUTO_INCREMENT PRIMARY KEY, user_id INT NOT NULL, token_hash CHAR(64) NOT NULL, expires_at DATETIME NOT NULL, used TINYINT NOT NULL DEFAULT 0, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, INDEX idx_token (token_hash), CONSTRAINT fk_reset_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4' ); $pdo->exec( 'CREATE TABLE IF NOT EXISTS user_scopes ( id INT NOT NULL AUTO_INCREMENT PRIMARY KEY, user_id INT NOT NULL, com VARCHAR(160) NOT NULL, depc VARCHAR(10) NOT NULL, UNIQUE KEY uniq_scope (user_id, com, depc), INDEX idx_comdep (com, depc), CONSTRAINT fk_scope_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4' ); // 3 bis. Groupes d'utilisateurs (équipes). can_validate = le groupe peut valider // les documents ; group_scopes porte alors son périmètre de communes. $pdo->exec( 'CREATE TABLE IF NOT EXISTS user_groups ( id INT NOT NULL AUTO_INCREMENT PRIMARY KEY, name VARCHAR(120) NOT NULL UNIQUE, can_validate TINYINT NOT NULL DEFAULT 0, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4' ); $pdo->exec( 'CREATE TABLE IF NOT EXISTS group_members ( group_id INT NOT NULL, user_id INT NOT NULL, PRIMARY KEY (group_id, user_id), INDEX idx_user (user_id), CONSTRAINT fk_gm_group FOREIGN KEY (group_id) REFERENCES user_groups(id) ON DELETE CASCADE, CONSTRAINT fk_gm_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4' ); $pdo->exec( 'CREATE TABLE IF NOT EXISTS group_scopes ( id INT NOT NULL AUTO_INCREMENT PRIMARY KEY, group_id INT NOT NULL, com VARCHAR(160) NOT NULL, depc VARCHAR(10) NOT NULL, UNIQUE KEY uniq_gscope (group_id, com, depc), INDEX idx_comdep (com, depc), CONSTRAINT fk_gs_group FOREIGN KEY (group_id) REFERENCES user_groups(id) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4' ); // 3 ter. Commentaires de fiche : horodatés, visibles entre membres d'un même groupe. $pdo->exec( 'CREATE TABLE IF NOT EXISTS comments ( id INT NOT NULL AUTO_INCREMENT PRIMARY KEY, uai VARCHAR(12) NOT NULL, item_id INT NULL, -- commentaire sur un document précis (sinon NULL) user_id INT NOT NULL, body TEXT NOT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, INDEX idx_uai (uai), INDEX idx_item (item_id), CONSTRAINT fk_com_uai FOREIGN KEY (uai) REFERENCES etablissements(id) ON DELETE CASCADE, CONSTRAINT fk_com_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4' ); // 4. Arborescence globale de dossiers. $pdo->exec( 'CREATE TABLE IF NOT EXISTS folders ( id INT NOT NULL AUTO_INCREMENT PRIMARY KEY, parent_id INT NULL, name VARCHAR(160) NOT NULL, position INT NOT NULL DEFAULT 0, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, INDEX idx_parent (parent_id), CONSTRAINT fk_folder_parent FOREIGN KEY (parent_id) REFERENCES folders(id) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4' ); // 5. Éléments : fichiers, photos, liens — soumis puis validés. $pdo->exec( "CREATE TABLE IF NOT EXISTS items ( id INT NOT NULL AUTO_INCREMENT PRIMARY KEY, uai VARCHAR(12) NOT NULL, folder_id INT NULL, kind ENUM('file','photo','link') NOT NULL, title VARCHAR(255) NOT NULL, description TEXT NULL, filename VARCHAR(255) NULL, url VARCHAR(2048) NULL, size BIGINT NULL, mime VARCHAR(160) NULL, status ENUM('pending','approved','rejected') NOT NULL DEFAULT 'pending', submitter_name VARCHAR(160) NULL, submitter_email VARCHAR(160) NULL, reject_reason VARCHAR(500) NULL, submitted_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, validated_by INT NULL, validated_at DATETIME NULL, INDEX idx_uai (uai), INDEX idx_status (status), INDEX idx_folder (folder_id), CONSTRAINT fk_item_uai FOREIGN KEY (uai) REFERENCES etablissements(id) ON DELETE CASCADE, CONSTRAINT fk_item_folder FOREIGN KEY (folder_id) REFERENCES folders(id) ON DELETE SET NULL, CONSTRAINT fk_item_validator FOREIGN KEY (validated_by) REFERENCES users(id) ON DELETE SET NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4" ); // Migrations des bases existantes : description des éléments + portée document des commentaires. add_col_if_missing($pdo, 'items', 'description', 'description TEXT NULL AFTER title'); add_col_if_missing($pdo, 'comments', 'item_id', 'item_id INT NULL AFTER uai'); // Index sur comments.item_id si absent. $hasIdx = $pdo->query("SHOW INDEX FROM comments WHERE Key_name = 'idx_item'")->fetchColumn(); if (!$hasIdx) { $pdo->exec('ALTER TABLE comments ADD INDEX idx_item (item_id)'); } say('✔ Tables prêtes.'); // 6. Dossiers d'upload. foreach (['files', 'photos'] as $d) { $p = __DIR__ . '/uploads/' . $d; if (!is_dir($p)) { mkdir($p, 0777, true); } } say('✔ Dossiers uploads/ prêts.'); // 7. Super-admin par défaut (changement de mot de passe forcé à la 1re connexion). if ((int) $pdo->query('SELECT COUNT(*) FROM users')->fetchColumn() === 0) { $pdo->prepare('INSERT INTO users (username, password_hash, role, must_change) VALUES (?, ?, ?, 1)') ->execute(['admin', password_hash('admin', PASSWORD_DEFAULT), 'super']); say('✔ Super-admin créé : « admin » / « admin » → changement de mot de passe demandé à la 1re connexion.'); } // 7 bis. Dossier privé (boîte d'envoi du mailer en mode « log »), non servi par Apache. $priv = __DIR__ . '/private'; if (!is_dir($priv)) { mkdir($priv, 0777, true); } if (!is_file($priv . '/.htaccess')) { file_put_contents($priv . '/.htaccess', "Require all denied\n"); } say('✔ Dossier private/ prêt.'); // 8. Arborescence d'exemple (modifiable ensuite dans l'admin). if ((int) $pdo->query('SELECT COUNT(*) FROM folders')->fetchColumn() === 0) { $tree = [ 'Administratif' => ['Budgets', 'Comptes-rendus de conseil'], 'Pédagogie' => ['Projet d’établissement', 'Règlement intérieur'], 'Travaux & bâtiments' => [], 'Photos' => ['Façade', 'Intérieur'], 'Liens utiles' => [], ]; $insTop = $pdo->prepare('INSERT INTO folders (parent_id, name, position) VALUES (NULL, ?, ?)'); $insSub = $pdo->prepare('INSERT INTO folders (parent_id, name, position) VALUES (?, ?, ?)'); $pos = 0; foreach ($tree as $top => $subs) { $insTop->execute([$top, $pos++]); $pid = (int) $pdo->lastInsertId(); $sp = 0; foreach ($subs as $sub) { $insSub->execute([$pid, $sub, $sp++]); } } say('✔ Arborescence d’exemple créée.'); } // 9. Import des établissements. $count = (int) $pdo->query('SELECT COUNT(*) FROM etablissements')->fetchColumn(); if ($count > 0 && !$reset) { say("ℹ $count établissements déjà en base — import ignoré (utilisez --reset / ?reset=1 pour réimporter)."); } else { $jsonPath = __DIR__ . '/data/etablissements.json'; if (!is_file($jsonPath)) { throw new RuntimeException("Fichier introuvable : $jsonPath"); } say('… Lecture de ' . basename($jsonPath) . ' …'); $rows = json_decode(file_get_contents($jsonPath), true); if (!is_array($rows)) { throw new RuntimeException('JSON invalide.'); } $n = import_etablissements($pdo, $rows); say("✔ Import terminé : $n établissements."); } say(''); say('🎉 Initialisation terminée.'); } catch (Throwable $e) { if (isset($pdo) && $pdo->inTransaction()) { $pdo->rollBack(); } http_response_code(500); say('!! ERREUR : ' . $e->getMessage()); exit(1); }