Publier Globinours 1.0.0-rc.3

This commit is contained in:
Alexandre NOEL 2026-09-03 12:39:15 +02:00
commit 9a2b4068da
325 changed files with 38230 additions and 20 deletions

View file

@ -0,0 +1,998 @@
<?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();
}
}
}