Globinours/app/Controllers/SettingsController.php

998 lines
40 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
declare(strict_types=1);
final class SettingsController
{
private const ROLES = PermissionService::ROLES;
public static function general(): void
{
self::admin();
render('settings_general.php', [
'title' => 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;
}
$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(),
'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');
if (
$name === '' ||
!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=?,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)),
$id,
]);
self::syncBoxes($db, $id, (string) ($row['boxes'] ?? ''));
}
$new = (array) ($_POST['new_room'] ?? []);
$newName = trim((string) ($new['name'] ?? ''));
if ($newName !== '') {
$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';
$db->prepare(
'INSERT INTO shelter_rooms(code,name,room_type,status_code,color,bulk_validation,sort_order) VALUES(?,?,?,?,?,?,?)',
)->execute([$code, $newName, $type, $status, $color, isset($new['bulk_validation']) ? 1 : 0, 100]);
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) {
$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 {
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(),
'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 saveAdoptionFees(): void
{
self::admin();
self::postOnly();
AppSettings::save(
[
'adoption_fee_sterilized' => (string) max(
0,
min(2000, (int) ($_POST['adoption_fee_sterilized'] ?? 220)),
),
'adoption_fee_unsterilized' => (string) max(
0,
min(2000, (int) ($_POST['adoption_fee_unsterilized'] ?? 200)),
),
],
Auth::id(),
);
AuditService::log('adoption_fees_saved', '/settings/pricing/adoption', 'Tarifs dadoption 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'] ?? ''),
]);
}
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();
render('settings_public_site.php', [
'title' => t('public_site.settings_title'),
'settings' => AppSettings::all(),
'saved' => isset($_GET['saved']),
'error' => (string) ($_GET['error'] ?? ''),
]);
}
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'], 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',
];
foreach (
[
'public_site_home_title' => 150,
'public_site_home_text' => 5000,
'public_site_about_title' => 150,
'public_site_about_text' => 10000,
'public_site_donation_title' => 150,
'public_site_donation_text' => 10000,
'public_site_contact_text' => 5000,
'public_site_legal_text' => 15000,
'public_site_privacy_text' => 15000,
]
as $key => $max
) {
$values[$key] = mb_substr(trim((string) ($_POST[$key] ?? '')), 0, $max);
}
foreach (['public_site_donation_url', 'public_site_facebook_url', 'public_site_instagram_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;
}
AppSettings::save($values, Auth::id());
AuditService::log('public_site_saved', '/settings/public-site/save', 'Configuration du site public modifiée');
header('Location: /settings/public-site?saved=1');
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();
}
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();
}
}
}