t('settings.general_title'), 'settings' => AppSettings::all(), 'saved' => isset($_GET['saved']), 'error' => (string) ($_GET['error'] ?? ''), 'associationLogo' => AssociationBrand::path(), ]); } public static function saveGeneral(): void { self::admin(); self::postOnly(); $values = []; foreach ( [ 'association_name', 'association_address', 'association_postal_code', 'association_city', 'association_phone', 'association_email', 'association_siret', 'association_rna', ] as $key ) { $values[$key] = trim((string) ($_POST[$key] ?? '')); } if ($values['association_name'] === '') { http_response_code(400); echo h(t('settings.association_required')); return; } if ($values['association_email'] !== '' && !filter_var($values['association_email'], FILTER_VALIDATE_EMAIL)) { http_response_code(400); echo h(t('settings.invalid_email')); return; } if (!Ids::validRna($values['association_rna'])) { header('Location: /settings/general?error=' . rawurlencode(t('settings.invalid_rna'))); exit(); } if (!Ids::validSiret($values['association_siret'])) { header('Location: /settings/general?error=' . rawurlencode(t('settings.invalid_siret'))); exit(); } $values['quarantine_days'] = (string) max(1, min(90, (int) ($_POST['quarantine_days'] ?? 15))); $values['backup_retention'] = (string) max(1, min(50, (int) ($_POST['backup_retention'] ?? 10))); $values['app_language'] = isset(I18n::LOCALES[(string) ($_POST['app_language'] ?? 'fr')]) ? (string) $_POST['app_language'] : 'fr'; try { $fullAddress = implode( ', ', array_filter([ $values['association_address'], trim($values['association_postal_code'] . ' ' . $values['association_city']), ]), ); if ($fullAddress === '') { $values['association_latitude'] = ''; $values['association_longitude'] = ''; } elseif ($point = GeoService::geocode(DB::pdo(), $fullAddress)) { $values['association_latitude'] = (string) $point['lat']; $values['association_longitude'] = (string) $point['lng']; } if (isset($_POST['delete_association_logo'])) { AssociationBrand::delete(); } elseif (isset($_FILES['association_logo'])) { AssociationBrand::store($_FILES['association_logo']); } AppSettings::save($values, Auth::id()); AuditService::log( 'settings_saved', '/settings/general/save', 'Paramètres généraux et identité visuelle modifiés', ); header('Location: /settings/general?saved=1'); } catch (Throwable $e) { header('Location: /settings/general?error=' . rawurlencode(SecurityService::publicError($e))); } exit(); } public static function associationLogo(): void { self::admin(); $path = AssociationBrand::path(); if (!$path) { http_response_code(404); return; } $mime = (new finfo(FILEINFO_MIME_TYPE))->file($path) ?: 'application/octet-stream'; header('Content-Type: ' . $mime); header('Content-Length: ' . filesize($path)); header('Cache-Control: private, max-age=3600'); header('X-Content-Type-Options: nosniff'); readfile($path); } public static function users(): void { self::admin(); $users = DB::pdo() ->query('SELECT * FROM users ORDER BY active DESC,display_name COLLATE NOCASE,username COLLATE NOCASE') ->fetchAll(PDO::FETCH_ASSOC); render('settings_users.php', ['title' => t('settings.users_title'), 'users' => $users, 'roles' => self::ROLES]); } public static function saveUser(): void { self::admin(); if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') { http_response_code(405); return; } $db = DB::pdo(); $id = (int) ($_POST['id'] ?? 0); $username = trim((string) ($_POST['username'] ?? '')); $display = trim((string) ($_POST['display_name'] ?? '')); $role = (string) ($_POST['role'] ?? 'lecture'); $active = isset($_POST['active']) ? 1 : 0; $password = (string) ($_POST['password'] ?? ''); if (!preg_match('/^[a-zA-Z0-9._-]{3,40}$/', $username) || $display === '' || !isset(self::ROLES[$role])) { http_response_code(400); echo h(t('settings.invalid_data')); return; } if ($id === Auth::id() && (!$active || $role !== 'admin')) { http_response_code(400); echo h(t('settings.keep_admin')); return; } if ($id) { $params = [':u' => $username, ':d' => $display, ':r' => $role, ':a' => $active, ':id' => $id]; $sql = "UPDATE users SET username=:u,display_name=:d,role=:r,active=:a,updated_at=datetime('now')"; if ($password !== '') { if (strlen($password) < 10) { http_response_code(400); echo h(t('settings.password_short')); return; } $sql .= ',password_hash=:p'; $params[':p'] = password_hash($password, PASSWORD_DEFAULT); } $sql .= ' WHERE id=:id'; $db->prepare($sql)->execute($params); $summary = 'Utilisateur modifié : ' . $display; } else { if (strlen($password) < 10) { http_response_code(400); echo h(t('settings.password_required')); return; } $db->prepare( 'INSERT INTO users(username,password_hash,display_name,role,active) VALUES(:u,:p,:d,:r,:a)', )->execute([ ':u' => $username, ':p' => password_hash($password, PASSWORD_DEFAULT), ':d' => $display, ':r' => $role, ':a' => $active, ]); $id = (int) $db->lastInsertId(); $summary = 'Utilisateur créé : ' . $display; } AuditService::log('user_saved', '/settings/users/save', $summary, 'user', $id, [ 'role' => $role, 'active' => $active, ]); header('Location: /settings/users'); exit(); } public static function permissions(): void { self::admin(); render('settings_permissions.php', [ 'title' => t('settings.permissions_title'), 'roles' => PermissionService::ROLES, 'modules' => PermissionService::MODULES, 'matrix' => PermissionService::matrix(), 'saved' => isset($_GET['saved']), ]); } public static function savePermissions(): void { self::admin(); self::postOnly(); PermissionService::save((array) ($_POST['permissions'] ?? []), Auth::id()); AuditService::log('permissions_saved', '/settings/permissions/save', 'Permissions des rôles modifiées'); header('Location: /settings/permissions?saved=1'); exit(); } public static function species(): void { self::admin(); render('settings_species.php', [ 'title' => t('settings.species_title'), 'species' => SpeciesService::all(), 'categories' => SpeciesService::CATEGORIES, 'saved' => isset($_GET['saved']), 'error' => (string) ($_GET['error'] ?? ''), ]); } public static function rooms(): void { self::admin(); render('settings_rooms.php', [ 'title' => t('rooms.title'), 'rooms' => ShelterRoomService::all(), 'species' => SpeciesService::all(), 'saved' => isset($_GET['saved']), 'error' => (string) ($_GET['error'] ?? ''), ]); } public static function saveRooms(): void { self::admin(); self::postOnly(); $db = DB::pdo(); $submitted = (array) ($_POST['rooms'] ?? []); try { $db->beginTransaction(); foreach ($submitted as $id => $row) { $id = (int) $id; if (!$id) { continue; } $name = trim((string) ($row['name'] ?? '')); $type = (string) ($row['room_type'] ?? 'collective'); $status = (string) ($row['status_code'] ?? 'refuge'); $color = (string) ($row['color'] ?? '#6c757d'); $allowedSpecies = SpeciesService::normalize((string) ($row['allowed_species'] ?? '')); if (!SpeciesService::exists($allowedSpecies, false)) { $allowedSpecies = null; } $allowedSex = in_array($row['allowed_sex'] ?? '', ['M', 'F'], true) ? (string) $row['allowed_sex'] : null; $requiredFiv = in_array($row['required_fiv'] ?? '', ['positive', 'negative'], true) ? (string) $row['required_fiv'] : 'any'; $periods = array_values(array_intersect(ShelterRoomService::PERIODS, (array) ($row['care_periods'] ?? []))); if (!$periods) { throw new RuntimeException(t('rooms.period_required')); } if ( $name === '' || mb_strlen($name) > 100 || !in_array($type, ['collective', 'boxes'], true) || !in_array($status, ['refuge', 'soin', 'quarantaine'], true) || !preg_match('/^#[0-9a-fA-F]{6}$/', $color) ) { throw new RuntimeException(t('rooms.invalid')); } $db->prepare( "UPDATE shelter_rooms SET name=?,room_type=?,status_code=?,color=?,bulk_validation=?,active=?,sort_order=?,allowed_species=?,allowed_sex=?,required_fiv=?,care_periods=?,updated_at=datetime('now') WHERE id=?", )->execute([ $name, $type, $status, $color, isset($row['bulk_validation']) ? 1 : 0, isset($row['active']) ? 1 : 0, max(0, (int) ($row['sort_order'] ?? 100)), $allowedSpecies, $allowedSex, $requiredFiv, implode(',', $periods), $id, ]); self::syncBoxes($db, $id, (string) ($row['boxes'] ?? '')); } $new = (array) ($_POST['new_room'] ?? []); $newName = trim((string) ($new['name'] ?? '')); if ($newName !== '') { if (mb_strlen($newName) > 100) { throw new RuntimeException(t('rooms.name_too_long')); } $code = ShelterRoomService::normalizeCode((string) ($new['code'] ?? $newName)); if (strlen($code) < 2) { throw new RuntimeException(t('rooms.invalid')); } $type = in_array($new['room_type'] ?? '', ['collective', 'boxes'], true) ? (string) $new['room_type'] : 'collective'; $status = in_array($new['status_code'] ?? '', ['refuge', 'soin', 'quarantaine'], true) ? (string) $new['status_code'] : 'refuge'; $color = preg_match('/^#[0-9a-fA-F]{6}$/', (string) ($new['color'] ?? '')) ? (string) $new['color'] : '#6c757d'; $allowedSpecies = SpeciesService::normalize((string) ($new['allowed_species'] ?? '')); if (!SpeciesService::exists($allowedSpecies, false)) { $allowedSpecies = null; } $allowedSex = in_array($new['allowed_sex'] ?? '', ['M', 'F'], true) ? (string) $new['allowed_sex'] : null; $requiredFiv = in_array($new['required_fiv'] ?? '', ['positive', 'negative'], true) ? (string) $new['required_fiv'] : 'any'; $periods = array_values(array_intersect(ShelterRoomService::PERIODS, (array) ($new['care_periods'] ?? []))); $periods = $periods ?: ['morning', 'evening']; $duplicate = $db->prepare('SELECT 1 FROM shelter_rooms WHERE code=? OR name=? COLLATE NOCASE'); $duplicate->execute([$code, $newName]); if ($duplicate->fetchColumn()) { throw new RuntimeException(t('rooms.duplicate')); } $db->prepare( 'INSERT INTO shelter_rooms(code,name,room_type,status_code,color,bulk_validation,sort_order,allowed_species,allowed_sex,required_fiv,care_periods) VALUES(?,?,?,?,?,?,?,?,?,?,?)', )->execute([$code, $newName, $type, $status, $color, isset($new['bulk_validation']) ? 1 : 0, 100, $allowedSpecies, $allowedSex, $requiredFiv, implode(',', $periods)]); self::syncBoxes($db, (int) $db->lastInsertId(), (string) ($new['boxes'] ?? '')); } $db->commit(); AuditService::log('rooms_saved', '/settings/rooms/save', 'Salles et box du refuge modifiés'); header('Location: /settings/rooms?saved=1'); } catch (Throwable $e) { if ($db->inTransaction()) { $db->rollBack(); } header('Location: /settings/rooms?error=' . rawurlencode(SecurityService::publicError($e))); } exit(); } private static function syncBoxes(PDO $db, int $roomId, string $lines): void { $names = array_values( array_filter(array_map('trim', preg_split('/\R/u', $lines) ?: []), static fn($name) => $name !== ''), ); $existing = $db->prepare('SELECT id,code FROM shelter_boxes WHERE room_id=? ORDER BY sort_order,id'); $existing->execute([$roomId]); $known = $existing->fetchAll(PDO::FETCH_ASSOC); $kept = []; foreach ($names as $index => $name) { if (mb_strlen($name) > 100) { throw new RuntimeException(t('rooms.box_name_too_long')); } $position = ($index + 1) * 10; if (isset($known[$index])) { $id = (int) $known[$index]['id']; $kept[] = $id; $db->prepare( "UPDATE shelter_boxes SET name=?,active=1,sort_order=?,updated_at=datetime('now') WHERE id=?", )->execute([$name, $position, $id]); continue; } $code = ShelterRoomService::normalizeCode($name) ?: 'box-' . ($index + 1); $base = $code; $suffix = 2; $exists = $db->prepare('SELECT 1 FROM shelter_boxes WHERE room_id=? AND code=?'); while (true) { $exists->execute([$roomId, $code]); if (!$exists->fetchColumn()) { break; } $code = $base . '-' . $suffix++; } $db->prepare('INSERT INTO shelter_boxes(room_id,code,name,sort_order) VALUES(?,?,?,?)')->execute([ $roomId, $code, $name, $position, ]); $kept[] = (int) $db->lastInsertId(); } foreach ($known as $box) { if (!in_array((int) $box['id'], $kept, true)) { $db->prepare("UPDATE shelter_boxes SET active=0,updated_at=datetime('now') WHERE id=?")->execute([ (int) $box['id'], ]); } } } public static function saveSpecies(): void { self::admin(); self::postOnly(); $db = DB::pdo(); $id = (int) ($_POST['id'] ?? 0); $name = trim((string) ($_POST['name'] ?? '')); $code = SpeciesService::normalize((string) ($_POST['code'] ?? '')); $category = (string) ($_POST['category'] ?? 'autre'); $icon = trim((string) ($_POST['icon'] ?? '')) ?: '🐾'; $color = trim((string) ($_POST['color'] ?? '#6c757d')); $active = isset($_POST['active']) ? 1 : 0; $sort = max(0, min(999, (int) ($_POST['sort_order'] ?? 100))); if ( $name === '' || !preg_match('/^[a-z0-9][a-z0-9-]{1,49}$/', $code) || !isset(SpeciesService::CATEGORIES[$category]) || !preg_match('/^#[0-9a-fA-F]{6}$/', $color) ) { header('Location: /settings/species?error=' . rawurlencode(t('settings.species_invalid'))); exit(); } try { $duplicate = $db->prepare( 'SELECT 1 FROM ref_species WHERE id<>:id AND (code=:code OR name=:name COLLATE NOCASE)', ); $duplicate->execute([':id' => $id, ':code' => $code, ':name' => $name]); if ($duplicate->fetchColumn()) { throw new RuntimeException(t('settings.species_duplicate')); } if ($id > 0) { $old = $db->prepare('SELECT code,is_system FROM ref_species WHERE id=:id'); $old->execute([':id' => $id]); $oldRow = $old->fetch(PDO::FETCH_ASSOC); if (!$oldRow) { throw new RuntimeException(t('settings.species_not_found')); } $oldCode = (string) $oldRow['code']; if ((int) $oldRow['is_system'] === 1) { $code = $oldCode; } $db->beginTransaction(); $db->prepare( 'UPDATE ref_species SET code=:code,name=:name,category=:category,icon=:icon,color=:color,active=:active,sort_order=:sort,updated_at=datetime(\'now\') WHERE id=:id', )->execute([ ':code' => $code, ':name' => $name, ':category' => $category, ':icon' => $icon, ':color' => $color, ':active' => $active, ':sort' => $sort, ':id' => $id, ]); if ($oldCode !== $code) { $db->prepare('UPDATE animals SET species=:new WHERE lower(trim(species))=lower(:old)')->execute([ ':new' => $code, ':old' => $oldCode, ]); } $db->commit(); } else { $db->prepare( 'INSERT INTO ref_species(code,name,category,icon,color,active,sort_order) VALUES(:code,:name,:category,:icon,:color,:active,:sort)', )->execute([ ':code' => $code, ':name' => $name, ':category' => $category, ':icon' => $icon, ':color' => $color, ':active' => $active, ':sort' => $sort, ]); } AuditService::log( 'species_saved', '/settings/species/save', 'Espèce enregistrée : ' . $name, 'species', $id ?: ((int) $db->lastInsertId()), ); header('Location: /settings/species?saved=1'); } catch (Throwable $e) { if ($db->inTransaction()) { $db->rollBack(); } header('Location: /settings/species?error=' . rawurlencode(SecurityService::publicError($e))); } exit(); } public static function pricing(): void { self::admin(); $db = DB::pdo(); $clinics = $db ->query( "SELECT DISTINCT dc.id,dc.name FROM directory_contacts dc JOIN directory_contact_roles r ON r.contact_id=dc.id AND r.role IN ('cabinet','crematorium','fourriere') WHERE dc.kind='organization' AND dc.deleted_at IS NULL ORDER BY dc.name COLLATE NOCASE", ) ->fetchAll(PDO::FETCH_ASSOC); $discounts = []; foreach ($db->query('SELECT * FROM clinic_discount_rules')->fetchAll(PDO::FETCH_ASSOC) as $r) { $discounts[(int) $r['clinic_contact_id']][(string) $r['discount_group']] = $r; } render('settings_pricing.php', [ 'title' => t('pricing.title'), 'tariffs' => PricingService::tariffs(), 'clinics' => $clinics, 'discounts' => $discounts, 'categories' => PricingService::CATEGORIES, 'groups' => PricingService::GROUPS, 'medications' => $db ->query('SELECT id,name FROM ref_medications ORDER BY name COLLATE NOCASE') ->fetchAll(PDO::FETCH_ASSOC), 'vaccines' => $db ->query('SELECT id,name FROM ref_vaccines ORDER BY name COLLATE NOCASE') ->fetchAll(PDO::FETCH_ASSOC), 'dewormers' => $db ->query('SELECT id,name FROM ref_dewormers ORDER BY name COLLATE NOCASE') ->fetchAll(PDO::FETCH_ASSOC), 'settings' => AppSettings::all(), 'adoptionFees' => PricingService::adoptionFees(), 'saved' => isset($_GET['saved']), 'error' => (string) ($_GET['error'] ?? ''), ]); } public static function saveTariff(): void { self::admin(); self::postOnly(); $db = DB::pdo(); $id = (int) ($_POST['id'] ?? 0); $clinic = (int) ($_POST['clinic_contact_id'] ?? 0); $label = trim((string) ($_POST['label'] ?? '')); $category = (string) ($_POST['category'] ?? 'other'); $group = (string) ($_POST['discount_group'] ?? 'act'); $unit = trim((string) ($_POST['unit_label'] ?? 'acte')) ?: 'acte'; $amount = PricingService::cents((string) ($_POST['amount'] ?? '0')); $active = isset($_POST['active']) ? 1 : 0; $eligible = $db->prepare( "SELECT 1 FROM directory_contacts dc WHERE dc.id=:id AND dc.kind='organization' AND dc.deleted_at IS NULL AND EXISTS(SELECT 1 FROM directory_contact_roles r WHERE r.contact_id=dc.id AND r.role IN ('cabinet','crematorium','fourriere'))", ); $eligible->execute([':id' => $clinic]); if ( !$clinic || !$eligible->fetchColumn() || $label === '' || !isset(PricingService::CATEGORIES[$category]) || !isset(PricingService::GROUPS[$group]) ) { header('Location: /settings/pricing?error=' . rawurlencode(t('pricing.invalid_structure'))); exit(); } $params = [ $clinic, mb_substr($label, 0, 180), $category, $group, mb_substr($unit, 0, 50), (int) ($_POST['medication_id'] ?? 0) ?: null, (int) ($_POST['vaccine_id'] ?? 0) ?: null, (int) ($_POST['dewormer_id'] ?? 0) ?: null, $amount, $active, trim((string) ($_POST['notes'] ?? '')) ?: null, ]; try { if ($id) { $params[] = $id; $db->prepare( "UPDATE clinic_tariffs SET clinic_contact_id=?,label=?,category=?,discount_group=?,unit_label=?,medication_id=?,vaccine_id=?,dewormer_id=?,amount_cents=?,active=?,notes=?,updated_at=datetime('now') WHERE id=?", )->execute($params); } else { $db->prepare( 'INSERT INTO clinic_tariffs(clinic_contact_id,label,category,discount_group,unit_label,medication_id,vaccine_id,dewormer_id,amount_cents,active,notes) VALUES(?,?,?,?,?,?,?,?,?,?,?)', )->execute($params); $id = (int) $db->lastInsertId(); } AuditService::log( 'tariff_saved', '/settings/pricing/tariff', 'Tarif enregistré : ' . $label, 'clinic_tariff', $id, ); header('Location: /settings/pricing?saved=1'); } catch (Throwable $e) { header('Location: /settings/pricing?error=' . rawurlencode(SecurityService::publicError($e))); } exit(); } public static function saveDiscount(): void { self::admin(); self::postOnly(); $clinic = (int) ($_POST['clinic_contact_id'] ?? 0); $submitted = (array) ($_POST['discounts'] ?? []); $db = DB::pdo(); $eligible = $db->prepare( "SELECT 1 FROM directory_contacts dc WHERE dc.id=:id AND dc.kind='organization' AND dc.deleted_at IS NULL AND EXISTS(SELECT 1 FROM directory_contact_roles r WHERE r.contact_id=dc.id AND r.role IN ('cabinet','crematorium','fourriere'))", ); $eligible->execute([':id' => $clinic]); if (!$clinic || !$eligible->fetchColumn()) { http_response_code(400); echo h(t('pricing.invalid_discount_structure')); return; } $stmt = $db->prepare( 'INSERT INTO clinic_discount_rules(clinic_contact_id,discount_group,label,percent) VALUES(?,?,?,?) ON CONFLICT(clinic_contact_id,discount_group) DO UPDATE SET label=excluded.label,percent=excluded.percent', ); $saved = []; try { $db->beginTransaction(); foreach (PricingService::GROUPS as $group => $label) { if ($group === 'none' || !array_key_exists($group, $submitted)) { continue; } $percent = max(0, min(100, (float) str_replace(',', '.', (string) $submitted[$group]))); $stmt->execute([$clinic, $group, $label, $percent]); $saved[$group] = $percent; } $db->commit(); AuditService::log( 'discount_saved', '/settings/pricing/discount', 'Remises tarifaires modifiées', 'clinic', $clinic, $saved, ); header('Location: /settings/pricing?saved=1'); } catch (Throwable $e) { if ($db->inTransaction()) { $db->rollBack(); } header('Location: /settings/pricing?error=' . rawurlencode(SecurityService::publicError($e))); } exit(); } public static function deleteTariff(): void { self::admin(); self::postOnly(); $id = (int) ($_POST['id'] ?? 0); $label = PricingService::deleteTariff($id); if ($label === null) { http_response_code(404); echo h(t('error.tariff_not_found')); return; } AuditService::log( 'tariff_deleted', '/settings/pricing/tariff/delete', 'Tarif supprimé : ' . $label, 'clinic_tariff', $id, ); header('Location: /settings/pricing?saved=1'); exit(); } public static function saveAdoptionFees(): void { self::admin(); self::postOnly(); $submitted = (array) ($_POST['fees'] ?? []); $allowed = array_keys(PricingService::adoptionFees()); $stmt = DB::pdo()->prepare( "INSERT INTO adoption_fee_rules(species_code,fee_before_sterilization,fee_after_sterilization,is_free,updated_at) VALUES(?,?,?,?,datetime('now')) ON CONFLICT(species_code) DO UPDATE SET fee_before_sterilization=excluded.fee_before_sterilization,fee_after_sterilization=excluded.fee_after_sterilization,is_free=excluded.is_free,updated_at=datetime('now')", ); foreach ($submitted as $species => $values) { $species = SpeciesService::normalize((string) $species); if (!in_array($species, $allowed, true) || !is_array($values)) { continue; } $stmt->execute([ $species, max(0, min(2000, (int) ($values['before'] ?? 0))), max(0, min(2000, (int) ($values['after'] ?? 0))), isset($values['free']) ? 1 : 0, ]); } AuditService::log('adoption_fees_saved', '/settings/pricing/adoption', 'Tarifs d’adoption modifiés'); header('Location: /settings/pricing?saved=1'); exit(); } public static function labReferences(): void { self::admin(); $rows = LabReferenceService::all(); $groups = []; foreach ($rows as $row) { $groups[$row['analyzer_name']][$row['species_code']][] = $row; } render('settings_lab_references.php', [ 'title' => t('lab_reference.title'), 'groups' => $groups, 'saved' => isset($_GET['saved']), 'error' => (string) ($_GET['error'] ?? ''), 'speciesOptions' => SpeciesService::all(true), ]); } public static function addLabReferenceSpecies(): void { self::admin(); self::postOnly(); try { $count = LabReferenceService::copySpecies( (string) ($_POST['source_species'] ?? ''), (string) ($_POST['target_species'] ?? ''), Auth::id(), ); AuditService::log('lab_reference_species_added', '/settings/lab-references/species', "$count références biologiques ajoutées"); header('Location: /settings/lab-references?saved=1'); } catch (Throwable $e) { header('Location: /settings/lab-references?error=' . rawurlencode(SecurityService::publicError($e))); } exit(); } public static function saveLabReferences(): void { self::admin(); self::postOnly(); $db = DB::pdo(); $input = (array) ($_POST['ranges'] ?? []); $stmt = $db->prepare( "UPDATE lab_reference_ranges SET parameter_name=?,unit=?,reference_min=?,reference_max=?,source_note=?,active=?,updated_by=?,updated_at=datetime('now') WHERE id=?", ); try { $db->beginTransaction(); foreach ($input as $id => $row) { $id = (int) $id; $name = trim((string) ($row['name'] ?? '')); $unit = trim((string) ($row['unit'] ?? '')); $min = self::nullableNumber($row['min'] ?? ''); $max = self::nullableNumber($row['max'] ?? ''); if (!$id || $name === '' || $unit === '' || ($min !== null && $max !== null && $min > $max)) { throw new RuntimeException(t('lab_reference.invalid')); } $stmt->execute([ mb_substr($name, 0, 120), mb_substr($unit, 0, 30), $min, $max, mb_substr(trim((string) ($row['source'] ?? '')), 0, 255) ?: null, isset($row['active']) ? 1 : 0, Auth::id(), $id, ]); } $db->commit(); AuditService::log( 'lab_references_saved', '/settings/lab-references/save', 'Intervalles de référence biologiques modifiés', ); header('Location: /settings/lab-references?saved=1'); } catch (RuntimeException $e) { if ($db->inTransaction()) { $db->rollBack(); } header('Location: /settings/lab-references?error=' . rawurlencode($e->getMessage())); } catch (Throwable $e) { if ($db->inTransaction()) { $db->rollBack(); } header('Location: /settings/lab-references?error=' . rawurlencode(SecurityService::publicError($e))); } exit(); } public static function publicSite(): void { self::admin(); $images = []; $mediaDirectory = dirname(__DIR__, 2) . '/public/media/site'; foreach (glob($mediaDirectory . '/*.{jpg,jpeg,png,webp,gif}', GLOB_BRACE) ?: [] as $path) { $filename = basename($path); $images[] = ['filename' => $filename, 'url' => '/media/site/' . rawurlencode($filename)]; } usort($images, static fn (array $left, array $right): int => strcmp($right['filename'], $left['filename'])); render('settings_public_site.php', [ 'title' => t('public_site.settings_title'), 'settings' => AppSettings::all(), 'saved' => isset($_GET['saved']), 'error' => (string) ($_GET['error'] ?? ''), 'images' => $images, 'customPages' => PublicPageService::all(), ]); } public static function uploadPublicSiteImage(): void { self::admin(); self::postOnly(); try { $upload = $_FILES['public_site_image'] ?? null; if (!is_array($upload) || (int) ($upload['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) { throw new RuntimeException(t('public_site.image_invalid')); } $directory = dirname(__DIR__, 2) . '/public/media/site'; $filename = ImageService::storeUploaded((string) $upload['tmp_name'], $directory, 'site', 'photo'); @chmod($directory, 0755); AuditService::log('public_site_image_uploaded', '/settings/public-site/image', 'Image du site public ajoutée'); if (str_contains((string) ($_SERVER['HTTP_ACCEPT'] ?? ''), 'application/json')) { header('Content-Type: application/json; charset=utf-8'); echo json_encode([ 'filename' => $filename, 'url' => '/media/site/' . rawurlencode($filename), ], JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR); exit(); } header('Location: /settings/public-site?image=' . rawurlencode($filename)); } catch (Throwable $e) { if (str_contains((string) ($_SERVER['HTTP_ACCEPT'] ?? ''), 'application/json')) { http_response_code(422); header('Content-Type: application/json; charset=utf-8'); echo json_encode(['error' => SecurityService::publicError($e)], JSON_THROW_ON_ERROR); exit(); } header('Location: /settings/public-site?error=' . rawurlencode(SecurityService::publicError($e))); } exit(); } public static function health(): void { self::admin(); render('settings_health.php', [ 'title' => t('health.page_title'), 'report' => SystemHealthService::report(), 'installed' => isset($_GET['installed']), ]); } public static function notifications(): void { self::admin(); render('settings_notifications.php', [ 'title' => t('notification.page_title'), 'settings' => AppSettings::all(), 'saved' => isset($_GET['saved']), 'sent' => (string) ($_GET['sent'] ?? ''), ]); } public static function saveNotifications(): void { self::admin(); self::postOnly(); $email = trim((string) ($_POST['notification_email_recipient'] ?? '')); if ($email !== '' && !filter_var($email, FILTER_VALIDATE_EMAIL)) { header('Location: /settings/notifications?sent=invalid'); exit(); } $frequency = in_array((string) ($_POST['notification_email_frequency'] ?? ''), ['daily', 'weekly'], true) ? (string) $_POST['notification_email_frequency'] : 'weekly'; AppSettings::save( [ 'notification_email_enabled' => isset($_POST['notification_email_enabled']) ? '1' : '0', 'notification_email_frequency' => $frequency, 'notification_email_recipient' => $email, ], Auth::id(), ); AuditService::log('notifications_saved', '/settings/notifications', 'Configuration des notifications modifiée'); header('Location: /settings/notifications?saved=1'); exit(); } public static function sendNotifications(): void { self::admin(); self::postOnly(); $result = NotificationService::send(true); AuditService::log( 'notification_digest', '/settings/notifications/send', 'Envoi manuel du résumé : ' . $result['reason'], ); header('Location: /settings/notifications?sent=' . rawurlencode((string) $result['reason'])); exit(); } public static function savePublicSite(): void { self::admin(); self::postOnly(); $theme = (string) ($_POST['public_site_theme'] ?? 'warm'); if (!in_array($theme, ['warm', 'nature', 'minimal', 'custom'], true)) { $theme = 'warm'; } $routing = (string) ($_POST['public_site_routing'] ?? 'integrated'); if (!in_array($routing, ['integrated', 'root'], true)) { $routing = 'integrated'; } $values = [ 'public_site_enabled' => isset($_POST['public_site_enabled']) ? '1' : '0', 'public_site_theme' => $theme, 'public_site_routing' => $routing, 'public_site_about_enabled' => isset($_POST['public_site_about_enabled']) ? '1' : '0', ]; $navigation = json_decode((string) ($_POST['public_site_navigation_order'] ?? '[]'), true); $navigation = is_array($navigation) ? array_values(array_unique(array_filter($navigation, static function ($item): bool { if (!is_string($item)) return false; return in_array($item, ['home','animals','about','donation','contact'], true) || (bool) preg_match('/^custom:[a-z0-9-]{1,80}$/', $item); }))) : []; $submittedPageSlugs = []; foreach ((array) ($_POST['pages'] ?? []) as $submittedPage) { if (!is_array($submittedPage) || !empty($submittedPage['delete'])) continue; $submittedSlug = PublicPageService::normalizeSlug((string) ($submittedPage['slug'] ?? $submittedPage['title'] ?? '')); if ($submittedSlug !== '') $submittedPageSlugs[] = $submittedSlug; } $navigation = array_values(array_filter($navigation, static function (string $item) use ($submittedPageSlugs): bool { return !str_starts_with($item, 'custom:') || in_array(substr($item, 7), $submittedPageSlugs, true); })); foreach (['home','animals','about','donation','contact'] as $required) { if (!in_array($required, $navigation, true)) $navigation[] = $required; } $values['public_site_navigation_order'] = json_encode($navigation, JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR); foreach (['public_site_home_title','public_site_name','public_site_about_title','public_site_donation_title'] as $key) { $values[$key] = mb_substr(trim((string) ($_POST[$key] ?? '')), 0, 150); } foreach (['public_site_home_text','public_site_about_text','public_site_donation_text','public_site_contact_text','public_site_legal_text','public_site_privacy_text'] as $key) { $values[$key] = trim((string) ($_POST[$key] ?? '')); } $customCss = trim((string) ($_POST['public_site_custom_css'] ?? '')); if (strlen($customCss) > 30000 || preg_match('/(?:<\/style|@import|expression\s*\(|javascript\s*:|behavior\s*:|-moz-binding|url\s*\()/i', $customCss)) { header('Location: /settings/public-site?error=' . rawurlencode(t('public_site.invalid_css'))); exit(); } $values['public_site_custom_css'] = $customCss; foreach ( [ 'public_site_donation_url', 'public_site_facebook_url', 'public_site_instagram_url', 'public_site_youtube_url', 'public_site_tiktok_url', 'public_site_whatsapp_url', 'public_site_linkedin_url', 'public_site_x_url', 'public_site_snapchat_url', ] as $key ) { $url = trim((string) ($_POST[$key] ?? '')); $scheme = strtolower((string) parse_url($url, PHP_URL_SCHEME)); if ($url !== '' && (!filter_var($url, FILTER_VALIDATE_URL) || $scheme !== 'https')) { header('Location: /settings/public-site?error=' . rawurlencode(t('public_site.invalid_url'))); exit(); } $values[$key] = $url; } $db = DB::pdo(); try { $db->beginTransaction(); AppSettings::save($values, Auth::id()); PublicPageService::saveAll((array) ($_POST['pages'] ?? [])); $db->commit(); AuditService::log('public_site_saved', '/settings/public-site/save', 'Configuration du site public modifiée'); header('Location: /settings/public-site?saved=1'); } catch (Throwable $e) { if ($db->inTransaction()) $db->rollBack(); $message = $e instanceof InvalidArgumentException ? $e->getMessage() : SecurityService::publicError($e); header('Location: /settings/public-site?error=' . rawurlencode($message)); } exit(); } public static function audit(): void { self::admin(); $rows = DB::pdo() ->query( 'SELECT al.*,COALESCE(u.display_name,u.username) actor FROM audit_log al LEFT JOIN users u ON u.id=al.user_id ORDER BY al.id DESC LIMIT 500', ) ->fetchAll(PDO::FETCH_ASSOC); render('settings_audit.php', ['title' => t('audit.title'), 'rows' => $rows]); } public static function dataTools(): void { self::admin(); $db = DB::pdo(); render('settings_data.php', [ 'title' => t('data.title'), 'counts' => [ 'animals' => (int) $db->query('SELECT COUNT(*) FROM animals')->fetchColumn(), 'contacts' => (int) $db->query('SELECT COUNT(*) FROM directory_contacts')->fetchColumn(), ], 'status' => (string) ($_GET['status'] ?? ''), 'backup' => (string) ($_GET['backup'] ?? ''), 'error' => (string) ($_GET['error'] ?? ''), ]); } public static function resetData(): void { self::dataOperation(false); } public static function loadDemoData(): void { self::dataOperation(true); } private static function dataOperation(bool $demo): void { self::admin(); self::postOnly(); $expected = t($demo ? 'data.confirm_demo_phrase' : 'data.confirm_reset_phrase'); if (trim((string) ($_POST['confirmation'] ?? '')) !== $expected) { header( 'Location: /settings/data?error=' . rawurlencode(t('data.confirm_required', ['phrase' => $expected])), ); exit(); } $db = DB::pdo(); try { $backup = BackupService::create($demo ? 'pre-demo-data' : 'pre-data-reset'); $db->beginTransaction(); $removed = DemoDataService::reset($db); $demo ? DemoDataService::seed($db, Auth::id()) : null; $db->commit(); DemoDataService::clearFiles(); if ($demo) { DemoDataService::installFiles($db); } AuditService::log( $demo ? 'demo_data_loaded' : 'business_data_reset', '/settings/data', $demo ? 'Base fictive de démonstration chargée' : 'Données métier remises à zéro', null, null, [ 'removed_animals' => $removed['animals'], 'removed_contacts' => $removed['contacts'], 'backup' => $backup['name'], ], ); header( 'Location: /settings/data?status=' . ($demo ? 'demo' : 'reset') . '&backup=' . rawurlencode($backup['name']), ); } catch (Throwable $e) { if ($db->inTransaction()) { $db->rollBack(); } header('Location: /settings/data?error=' . rawurlencode(SecurityService::publicError($e))); } exit(); } public static function backups(): void { self::admin(); render('settings_backups.php', [ 'title' => t('backups.title'), 'backups' => BackupService::list(), 'status' => (string) ($_GET['status'] ?? ''), 'error' => (string) ($_GET['error'] ?? ''), ]); } public static function createBackup(): void { self::admin(); self::postOnly(); try { $backup = BackupService::create(); AuditService::log('backup_created', '/settings/backups/create', 'Sauvegarde créée : ' . $backup['name']); header('Location: /settings/backups?status=created'); } catch (Throwable $e) { header('Location: /settings/backups?error=' . rawurlencode(SecurityService::publicError($e))); } exit(); } public static function saveBackupSchedule(): void { self::admin(); self::postOnly(); $frequency = (string) ($_POST['backup_schedule_frequency'] ?? 'daily'); if (!in_array($frequency, ['daily', 'weekly'], true)) { $frequency = 'daily'; } AppSettings::save( [ 'backup_schedule_enabled' => isset($_POST['backup_schedule_enabled']) ? '1' : '0', 'backup_schedule_frequency' => $frequency, ], Auth::id(), ); AuditService::log( 'backup_schedule_saved', '/settings/backups/schedule', 'Planification des sauvegardes modifiée', ); header('Location: /settings/backups?status=schedule'); exit(); } public static function verifyBackup(): void { self::admin(); self::postOnly(); try { $result = BackupService::verify((string) ($_POST['file'] ?? '')); AppSettings::save(['backup_last_verified_at' => date('Y-m-d H:i:s')], Auth::id()); AuditService::log( 'backup_verified', '/settings/backups/verify', 'Sauvegarde vérifiée : ' . $result['name'], ); header('Location: /settings/backups?status=verified'); } catch (RuntimeException $e) { header('Location: /settings/backups?error=' . rawurlencode($e->getMessage())); } catch (Throwable $e) { header('Location: /settings/backups?error=' . rawurlencode(SecurityService::publicError($e))); } exit(); } public static function downloadBackup(): void { self::admin(); $path = BackupService::path((string) ($_GET['file'] ?? '')); if (!$path) { http_response_code(404); echo h(t('backups.not_found')); return; } PrivacyService::logAccess('backup', null, null, basename($path)); AuditService::log('private_file_viewed', '/settings/backups/download', 'Sauvegarde privée téléchargée'); header('Content-Type: application/zip'); header('Content-Disposition: attachment; filename="' . basename($path) . '"'); header('Content-Length: ' . filesize($path)); header('Cache-Control: private,no-store'); header('X-Content-Type-Options: nosniff'); readfile($path); } public static function deleteBackup(): void { self::admin(); self::postOnly(); $name = (string) ($_POST['file'] ?? ''); try { BackupService::delete($name); AuditService::log('backup_deleted', '/settings/backups/delete', 'Sauvegarde supprimée : ' . $name); header('Location: /settings/backups?status=deleted'); } catch (RuntimeException $e) { header('Location: /settings/backups?error=' . rawurlencode($e->getMessage())); } catch (Throwable $e) { header('Location: /settings/backups?error=' . rawurlencode(SecurityService::publicError($e))); } exit(); } public static function restoreBackup(): void { self::admin(); self::postOnly(); $upload = $_FILES['backup'] ?? null; if ( !is_array($upload) || ($upload['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK || !is_uploaded_file((string) $upload['tmp_name']) ) { header('Location: /settings/backups?error=' . rawurlencode(t('backups.invalid_file'))); exit(); } if ((int) ($upload['size'] ?? 0) > 1_000_000_000) { header('Location: /settings/backups?error=' . rawurlencode(t('backups.too_large'))); exit(); } try { BackupService::restore((string) $upload['tmp_name']); Auth::logout(); header('Location: /login?restored=1'); } catch (RuntimeException $e) { header('Location: /settings/backups?error=' . rawurlencode($e->getMessage())); } catch (Throwable $e) { header('Location: /settings/backups?error=' . rawurlencode(SecurityService::publicError($e))); } exit(); } public static function updates(): void { self::admin(); render('settings_updates.php', [ 'title' => t('updates.title'), 'updateStatus' => UpdateService::cachedStatus(), 'checks' => UpdateService::preflight(), 'lastUpdate' => UpdateService::lastUpdate(), 'status' => (string) ($_GET['status'] ?? ''), 'error' => (string) ($_GET['error'] ?? ''), ]); } public static function checkUpdates(): void { self::admin(); self::postOnly(); try { UpdateService::check(); header('Location: /settings/updates?status=checked'); } catch (Throwable $e) { header('Location: /settings/updates?error=' . rawurlencode(SecurityService::publicError($e))); } exit(); } public static function installUpdate(): void { self::admin(); self::postOnly(); if (!Auth::verifyCurrentPassword((string) ($_POST['password'] ?? ''))) { header('Location: /settings/updates?error=' . rawurlencode(t('updates.password_invalid'))); exit(); } try { @set_time_limit(0); $result = UpdateService::installFromRepository(); AuditService::log('application_updated', '/settings/updates/install', 'Globinours mis à jour vers ' . $result['to']); header('Location: /settings/updates?status=installed&version=' . rawurlencode($result['to'])); } catch (Throwable $e) { header('Location: /settings/updates?error=' . rawurlencode(SecurityService::publicError($e))); } exit(); } public static function uploadUpdate(): void { self::admin(); self::postOnly(); $upload = $_FILES['release'] ?? null; if (!Auth::verifyCurrentPassword((string) ($_POST['password'] ?? ''))) { header('Location: /settings/updates?error=' . rawurlencode(t('updates.password_invalid'))); exit(); } if (!is_array($upload) || ($upload['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK || !is_uploaded_file((string) ($upload['tmp_name'] ?? ''))) { header('Location: /settings/updates?error=' . rawurlencode(t('updates.archive_invalid'))); exit(); } try { @set_time_limit(0); $result = UpdateService::install((string) $upload['tmp_name'], (string) ($_POST['sha256'] ?? ''), 'upload'); AuditService::log('application_updated', '/settings/updates/upload', 'Globinours mis à jour vers ' . $result['to']); header('Location: /settings/updates?status=installed&version=' . rawurlencode($result['to'])); } catch (Throwable $e) { header('Location: /settings/updates?error=' . rawurlencode(SecurityService::publicError($e))); } exit(); } public static function rollbackUpdate(): void { self::admin(); self::postOnly(); if (!Auth::verifyCurrentPassword((string) ($_POST['password'] ?? ''))) { header('Location: /settings/updates?error=' . rawurlencode(t('updates.password_invalid'))); exit(); } try { @set_time_limit(0); $result = UpdateService::rollbackLast(); Auth::logout(); header('Location: /login?rollback=1&version=' . rawurlencode($result['version'])); } catch (Throwable $e) { header('Location: /settings/updates?error=' . rawurlencode(SecurityService::publicError($e))); } exit(); } public static function downloadUpdateLog(): void { self::admin(); $path = UpdateService::logPath(); if (!$path) { http_response_code(404); echo h(t('updates.log_missing')); return; } header('Content-Type: text/plain; charset=utf-8'); header('Content-Disposition: attachment; filename="globinours-update.log"'); header('Content-Length: ' . filesize($path)); header('Cache-Control: private, no-store'); header('X-Content-Type-Options: nosniff'); readfile($path); } public static function catalogs(): void { self::admin(); $db = DB::pdo(); render('settings_catalogs.php', [ 'title' => t('settings.catalogs'), 'catalogs' => [ 'medication' => $db->query('SELECT id,name,molecule extra FROM ref_medications ORDER BY name COLLATE NOCASE')->fetchAll(PDO::FETCH_ASSOC), 'vaccine' => $db->query('SELECT id,name,code extra FROM ref_vaccines ORDER BY name COLLATE NOCASE')->fetchAll(PDO::FETCH_ASSOC), 'dewormer' => $db->query('SELECT id,name,active_ingredient extra FROM ref_dewormers ORDER BY name COLLATE NOCASE')->fetchAll(PDO::FETCH_ASSOC), 'manufacturer' => $db->query('SELECT id,name,NULL extra FROM ref_vaccine_manufacturers ORDER BY name COLLATE NOCASE')->fetchAll(PDO::FETCH_ASSOC), ], 'saved' => isset($_GET['saved']), 'error' => (string) ($_GET['error'] ?? ''), ]); } public static function saveCatalogItem(): void { self::admin(); self::postOnly(); $type = (string) ($_POST['type'] ?? ''); $id = (int) ($_POST['id'] ?? 0); $name = trim((string) ($_POST['name'] ?? '')); $extra = trim((string) ($_POST['extra'] ?? '')) ?: null; $map = [ 'medication' => ['ref_medications','molecule'], 'vaccine' => ['ref_vaccines','code'], 'dewormer' => ['ref_dewormers','active_ingredient'], 'manufacturer' => ['ref_vaccine_manufacturers',null], ]; if ($name === '' || !isset($map[$type])) { header('Location: /settings/catalogs?error=' . rawurlencode(t('catalog.invalid'))); exit(); } [$table,$extraColumn] = $map[$type]; try { if ($id) { $sql = "UPDATE $table SET name=?" . ($extraColumn ? ",$extraColumn=?" : '') . ' WHERE id=?'; DB::pdo()->prepare($sql)->execute($extraColumn ? [$name,$extra,$id] : [$name,$id]); } else { $sql = "INSERT INTO $table(name" . ($extraColumn ? ",$extraColumn" : '') . ') VALUES(?' . ($extraColumn ? ',?' : '') . ')'; DB::pdo()->prepare($sql)->execute($extraColumn ? [$name,$extra] : [$name]); $id = (int) DB::pdo()->lastInsertId(); } AuditService::log('catalog_saved', '/settings/catalogs/save', 'Catalogue modifié : ' . $name, $type, $id); header('Location: /settings/catalogs?saved=1'); } catch (Throwable $e) { header('Location: /settings/catalogs?error=' . rawurlencode(SecurityService::publicError($e))); } exit(); } public static function deleteCatalogItem(): void { self::admin(); self::postOnly(); $type = (string) ($_POST['type'] ?? ''); $id = (int) ($_POST['id'] ?? 0); $tables = ['medication'=>'ref_medications','vaccine'=>'ref_vaccines','dewormer'=>'ref_dewormers','manufacturer'=>'ref_vaccine_manufacturers']; if (!$id || !isset($tables[$type])) { http_response_code(400); return; } try { DB::pdo()->prepare('DELETE FROM ' . $tables[$type] . ' WHERE id=?')->execute([$id]); AuditService::log('catalog_deleted', '/settings/catalogs/delete', 'Élément de catalogue supprimé', $type, $id); header('Location: /settings/catalogs?saved=1'); } catch (Throwable) { header('Location: /settings/catalogs?error=' . rawurlencode(t('catalog.in_use'))); } exit(); } private static function postOnly(): void { if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') { http_response_code(405); header('Allow: POST'); exit(); } } private static function nullableNumber(mixed $value): ?float { $value = str_replace(',', '.', trim((string) $value)); if ($value === '') { return null; } if (!is_numeric($value)) { throw new RuntimeException(t('lab_reference.invalid')); } return (float) $value; } private static function admin(): void { if (!Auth::is('admin')) { http_response_code(403); echo h(t('settings.admin_only')); exit(); } } }