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,602 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/AnimalMedicalActions.php';
require_once __DIR__ . '/AnimalPhotoActions.php';
require_once __DIR__ . '/AnimalRelationshipActions.php';
require_once __DIR__ . '/AnimalLifecycleActions.php';
require_once __DIR__ . '/AnimalBrowseActions.php';
require_once __DIR__ . '/AnimalRecordActions.php';
final class AnimalsController
{
use AnimalBrowseActions;
use AnimalMedicalActions;
use AnimalPhotoActions;
use AnimalRecordActions;
use AnimalLifecycleActions;
private const INTAKE_TYPES = [
'unknown' => 'Non renseigné',
'found' => 'Trouvé',
'owner_surrender' => 'Cession par le propriétaire',
'transfer' => 'Transfert dune autre structure',
'born_in_care' => 'Né sous la responsabilité du refuge',
'return' => 'Retour après placement',
'seizure' => 'Saisie ou réquisition',
'other' => 'Autre mode dentrée',
];
private const INTAKE_REASONS = [
'unknown' => 'Non renseigné',
'stray_found' => 'Animal errant ou trouvé',
'unwanted_litter' => 'Portée non désirée',
'owner_health' => 'Santé ou hospitalisation du propriétaire',
'owner_death' => 'Décès du propriétaire',
'owner_care_home' => 'Entrée en EHPAD ou établissement spécialisé du propriétaire',
'allergy' => 'Allergie',
'housing' => 'Logement ou déménagement',
'financial' => 'Difficultés financières',
'behavior' => 'Difficultés comportementales',
'abandonment' => 'Abandon sur place',
'danger' => 'Mise en danger ou maltraitance',
'transfer' => 'Transfert entre structures',
'birth' => 'Naissance',
'other' => 'Autre motif',
];
private static function intakeTypes(): array
{
$out = [];
foreach (self::INTAKE_TYPES as $key => $fallback) {
$out[$key] = t('statistics.intake.' . $key, [], $fallback);
}
return $out;
}
private static function intakeReasons(): array
{
$out = [];
foreach (self::INTAKE_REASONS as $key => $fallback) {
$out[$key] = t('statistics.reason.' . $key, [], $fallback);
}
return $out;
}
public static function history(): void
{
$db = DB::pdo();
$id = (int) ($_GET['id'] ?? 0);
if ($id <= 0) {
http_response_code(400);
return;
}
$a = $db->prepare('SELECT * FROM animals WHERE id=:id');
$a->execute([':id' => $id]);
$animal = $a->fetch(PDO::FETCH_ASSOC);
if (!$animal) {
http_response_code(404);
return;
}
$h = $db->prepare('
SELECT ah.*,COALESCE(u.display_name,u.username) actor_name FROM animal_history ah
LEFT JOIN users u ON u.id=ah.user_id
WHERE ah.animal_id=:id
ORDER BY ah.created_at DESC,ah.id DESC
LIMIT 500
');
$h->execute([':id' => $id]);
$history = $h->fetchAll(PDO::FETCH_ASSOC);
if (!PermissionService::can('medical')) {
$history = array_values(
array_filter(
$history,
static fn(array $row): bool => !in_array(
(string) ($row['type'] ?? ''),
['medical', 'treatment', 'vaccine', 'deworming', 'death'],
true,
),
),
);
}
self::render('animal_history', [
'title' => 'Historique ' . ($animal['name'] ?? ''),
'animal' => $animal,
'history' => $history,
]);
}
private static function logHistory(int $animalId, string $type, string $label, ?string $details = null): void
{
$db = DB::pdo();
$db->prepare(
'
INSERT INTO animal_history (animal_id, type, label, details, user_id)
VALUES (:aid, :type, :label, :details, :user)
',
)->execute([
':aid' => $animalId,
':type' => $type,
':label' => $label,
':details' => $details,
':user' => Auth::id(),
]);
}
private static function syncIntakeMovement(
PDO $db,
int $animalId,
?string $date,
string $type,
string $reason,
?int $depositorId,
string $municipality,
string $address,
string $circumstances,
): void {
$contact = null;
if ($depositorId) {
$s = $db->prepare('SELECT name,phone,email FROM directory_contacts WHERE id=:id AND deleted_at IS NULL');
$s->execute([':id' => $depositorId]);
$contact = $s->fetch(PDO::FETCH_ASSOC) ?: null;
}
$s = $db->prepare(
"SELECT id FROM animal_movements WHERE animal_id=:animal AND kind='entry' ORDER BY id ASC LIMIT 1",
);
$s->execute([':animal' => $animalId]);
$movementId = (int) $s->fetchColumn();
$typeLabel = self::intakeTypes()[$type] ?? self::intakeTypes()['unknown'];
$reasonLabel = self::intakeReasons()[$reason] ?? self::intakeReasons()['unknown'];
$note = 'Motif : ' . $reasonLabel;
if ($circumstances !== '') {
$note .= "\nCirconstances : " . $circumstances;
}
$values = [
':animal' => $animalId,
':place' => $municipality !== '' ? $municipality : ($address !== '' ? $address : null),
':lieu' => $typeLabel,
':name' => $contact['name'] ?? null,
':phone' => $contact['phone'] ?? null,
':email' => $contact['email'] ?? null,
':note' => $note,
':at' => ($date ?: date('Y-m-d')) . ' 12:00:00',
];
if ($movementId) {
$values[':id'] = $movementId;
$db->prepare(
'UPDATE animal_movements SET place=:place,lieu=:lieu,contact_name=:name,contact_phone=:phone,contact_email=:email,note=:note,created_at=:at WHERE id=:id AND animal_id=:animal',
)->execute($values);
} else {
$db->prepare(
"INSERT INTO animal_movements(animal_id,kind,place,lieu,contact_name,contact_phone,contact_email,note,created_at) VALUES(:animal,'entry',:place,:lieu,:name,:phone,:email,:note,:at)",
)->execute($values);
}
}
private static function now(): string
{
return date('Y-m-d H:i:s');
}
private static function animalMediaDir(int $animalId): string
{
// public/media/animals/{id}
return __DIR__ . '/../../public/media/animals/' . $animalId;
}
private static function ensureAnimalMediaDir(int $animalId): void
{
$dir = self::animalMediaDir($animalId);
if (!is_dir($dir)) {
mkdir($dir, 0775, true);
}
}
private static function safeFilename(string $name): string
{
$name = preg_replace('~[^a-zA-Z0-9._-]+~', '_', $name);
$name = trim($name, '._-');
return $name === '' ? 'photo' : $name;
}
private static function resolveDirectoryContact(
PDO $db,
int $id,
string $newName,
string $kind,
string $role,
): ?array {
if ($id > 0) {
$stmt = $db->prepare(
'SELECT dc.id,dc.name,dc.organization_id FROM directory_contacts dc WHERE dc.id=:id AND dc.kind=:kind AND dc.deleted_at IS NULL AND EXISTS(SELECT 1 FROM directory_contact_roles r WHERE r.contact_id=dc.id AND r.role=:role)',
);
$stmt->execute([':id' => $id, ':kind' => $kind, ':role' => $role]);
return $stmt->fetch(PDO::FETCH_ASSOC) ?: null;
}
$newName = trim($newName);
if ($newName === '') {
return null;
}
$stmt = $db->prepare(
'SELECT id,name FROM directory_contacts WHERE deleted_at IS NULL AND kind=:kind AND lower(name)=lower(:name) LIMIT 1',
);
$stmt->execute([':kind' => $kind, ':name' => $newName]);
$contact = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$contact) {
$db->prepare('INSERT INTO directory_contacts(kind,name) VALUES(:kind,:name)')->execute([
':kind' => $kind,
':name' => $newName,
]);
$contact = ['id' => (int) $db->lastInsertId(), 'name' => $newName, 'organization_id' => null];
}
$db->prepare('INSERT OR IGNORE INTO directory_contact_roles(contact_id,role) VALUES(:id,:role)')->execute([
':id' => $contact['id'],
':role' => $role,
]);
return $contact;
}
private static function normalizeVeterinaryClinic(PDO $db, ?array &$vet, ?array &$clinic): void
{
if (!$vet) {
return;
}
$organizationId = (int) ($vet['organization_id'] ?? 0);
if ($organizationId > 0) {
$s = $db->prepare(
"SELECT id,name,organization_id FROM directory_contacts WHERE id=:id AND kind='organization' AND deleted_at IS NULL",
);
$s->execute([':id' => $organizationId]);
$linked = $s->fetch(PDO::FETCH_ASSOC);
if ($linked) {
$clinic = $linked;
}
return;
}
if (!$clinic) {
return;
}
$db->prepare(
"UPDATE directory_contacts SET organization_id=:clinic,updated_at=datetime('now') WHERE id=:vet AND kind='person'",
)->execute([':clinic' => $clinic['id'], ':vet' => $vet['id']]);
$vet['organization_id'] = $clinic['id'];
}
private static function isAllowedImage(string $tmpPath): bool
{
$info = @getimagesize($tmpPath);
if (!$info) {
return false;
}
$mime = $info['mime'] ?? '';
return in_array($mime, ['image/jpeg', 'image/png', 'image/webp', 'image/gif'], true);
}
public static function togglePhotoVisibility(): void
{
if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') {
http_response_code(405);
return;
}
$animalId = (int) ($_POST['animal_id'] ?? 0);
$photoId = (int) ($_POST['photo_id'] ?? 0);
$isPublic = (int) ($_POST['is_public'] ?? 1 ? 1 : 0);
if ($animalId <= 0 || $photoId <= 0) {
http_response_code(400);
echo h(t('error.bad_request'));
return;
}
// onglet retour
$redirectHash = trim((string) ($_POST['redirect_hash'] ?? '#tab-photos'));
$hash = $redirectHash !== '' && $redirectHash[0] === '#' ? $redirectHash : '#tab-photos';
$db = DB::pdo();
// Récupère la photo ciblée + état actuel
$st = $db->prepare('
SELECT id, is_primary, is_public, filename
FROM animal_photos
WHERE id = :pid AND animal_id = :aid
LIMIT 1
');
$st->execute([':pid' => $photoId, ':aid' => $animalId]);
$photo = $st->fetch(PDO::FETCH_ASSOC);
if (!$photo) {
http_response_code(404);
echo h(t('error.photo_not_found'));
return;
}
$wasPrimary = (int) ($photo['is_primary'] ?? 0) === 1;
$curPublic = (int) ($photo['is_public'] ?? 1);
// Rien à faire si déjà le bon état
if ($curPublic === $isPublic) {
header('Location: /animal?id=' . $animalId . $hash);
exit();
}
try {
PrivateMediaService::moveVisibility($animalId, (string) $photo['filename'], $isPublic === 1);
$db->beginTransaction();
// 1) Toggle visibilité
$db->prepare(
'
UPDATE animal_photos
SET is_public = :pub
WHERE id = :pid AND animal_id = :aid
',
)->execute([
':pub' => $isPublic,
':pid' => $photoId,
':aid' => $animalId,
]);
// 2) Si on rend PRIVÉ une photo qui était PRINCIPALE → on lui retire le flag principale
// et on promeut une autre photo publique (la plus récente) si possible.
if ($isPublic === 0 && $wasPrimary) {
// Retire principale à celle-ci
$db->prepare(
'
UPDATE animal_photos
SET is_primary = 0
WHERE id = :pid AND animal_id = :aid
',
)->execute([':pid' => $photoId, ':aid' => $animalId]);
// Cherche une photo publique à promouvoir
$newPrimaryId = $db
->query(
'
SELECT id
FROM animal_photos
WHERE animal_id = ' .
(int) $animalId .
'
AND is_public = 1
ORDER BY is_primary DESC, id DESC
LIMIT 1
',
)
->fetchColumn();
if ($newPrimaryId) {
// Met toutes les autres à 0 puis celle-là à 1 (propre)
$db->prepare('UPDATE animal_photos SET is_primary=0 WHERE animal_id=:aid')->execute([
':aid' => $animalId,
]);
$db->prepare('UPDATE animal_photos SET is_primary=1 WHERE id=:pid AND animal_id=:aid')->execute([
':pid' => (int) $newPrimaryId,
':aid' => $animalId,
]);
} else {
// aucune publique => aucune principale (OK)
$db->prepare('UPDATE animal_photos SET is_primary=0 WHERE animal_id=:aid')->execute([
':aid' => $animalId,
]);
}
}
// 3) Si on rend PUBLIC et qu'il n'y a aucune principale → cette photo devient principale
if ($isPublic === 1) {
$hasPrimary = (bool) $db
->query(
'
SELECT 1
FROM animal_photos
WHERE animal_id=' .
(int) $animalId .
' AND is_primary=1
LIMIT 1
',
)
->fetchColumn();
if (!$hasPrimary) {
// On peut la promouvoir
$db->prepare('UPDATE animal_photos SET is_primary=0 WHERE animal_id=:aid')->execute([
':aid' => $animalId,
]);
$db->prepare('UPDATE animal_photos SET is_primary=1 WHERE id=:pid AND animal_id=:aid')->execute([
':pid' => $photoId,
':aid' => $animalId,
]);
}
}
$db->commit();
} catch (Throwable $e) {
if ($db->inTransaction()) {
$db->rollBack();
}
try {
PrivateMediaService::moveVisibility($animalId, (string) $photo['filename'], $isPublic !== 1);
} catch (Throwable) {
}
throw $e;
}
// Historique (optionnel)
if (method_exists(__CLASS__, 'logHistory')) {
self::logHistory(
$animalId,
'photo',
$isPublic ? 'Photo rendue publique' : 'Photo rendue privée',
(string) ($photo['filename'] ?? ''),
);
}
$db->prepare("UPDATE animals SET updated_at=datetime('now') WHERE id=:id")->execute([':id' => $animalId]);
header('Location: /animal?id=' . $animalId . $hash);
exit();
}
public static function icad(): void
{
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') {
http_response_code(405);
header('Allow: POST');
return;
}
$db = DB::pdo();
$id = (int) ($_POST['id'] ?? 0);
if ($id <= 0) {
http_response_code(400);
echo h(t('error.bad_request'));
return;
}
$force = (int) ($_POST['force'] ?? 0) === 1;
// animal + puce
$st = $db->prepare('SELECT id, chip_id FROM animals WHERE id=:id LIMIT 1');
$st->execute([':id' => $id]);
$animal = $st->fetch(PDO::FETCH_ASSOC);
if (!$animal) {
http_response_code(404);
echo h(t('error.animal_not_found'));
return;
}
$chipId = preg_replace('/\D+/', '', (string) ($animal['chip_id'] ?? ''));
if ($chipId === '') {
header('Content-Type: application/json; charset=utf-8');
echo json_encode(['cached' => null, 'stale' => true, 'error' => 'chip_id vide'], JSON_UNESCAPED_UNICODE);
return;
}
require_once __DIR__ . '/../Services/IcadService.php';
$svc = new IcadService($db);
$cached = $svc->getCachedByAnimal($id);
$stale = $svc->isStale($cached, 86400);
if ($force || $stale) {
try {
$svc->refreshByChip($id, $chipId);
} catch (Throwable $e) {
// on laisse le cache en "error"
}
$cached = $svc->getCachedByAnimal($id);
$stale = $svc->isStale($cached, 86400);
}
header('Content-Type: application/json; charset=utf-8');
echo json_encode(['cached' => $cached, 'stale' => $stale], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
}
public static function geocode(): void
{
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') {
http_response_code(405);
header('Allow: POST');
return;
}
$db = DB::pdo();
$id = (int) ($_POST['id'] ?? 0);
if ($id <= 0) {
http_response_code(400);
echo h(t('error.bad_request'));
return;
}
$force = (int) ($_POST['force'] ?? 0) === 1;
// Récupérer les adresses + coords existantes
$st = $db->prepare('
SELECT id,
rescue_address, rescue_lat, rescue_lng,
current_address, current_lat, current_lng
FROM animals
WHERE id=:id
LIMIT 1
');
$st->execute([':id' => $id]);
$a = $st->fetch(PDO::FETCH_ASSOC);
if (!$a) {
http_response_code(404);
echo h(t('error.animal_not_found'));
return;
}
require_once __DIR__ . '/../Services/GeocodeService.php';
$updates = [];
$debug = [
'animal_id' => $id,
'force' => $force,
'rescue' => null,
'current' => null,
];
// Rescue
$rescueAddr = trim((string) ($a['rescue_address'] ?? ''));
$hasRescueCoords = $a['rescue_lat'] !== null && $a['rescue_lng'] !== null;
if ($rescueAddr !== '' && ($force || !$hasRescueCoords)) {
$g = GeocodeService::geocode($rescueAddr);
$debug['rescue'] = ['query' => $rescueAddr, 'result' => $g];
if ($g) {
$updates['rescue_lat'] = $g['lat'];
$updates['rescue_lng'] = $g['lng'];
}
}
// Current
$currentAddr = trim((string) ($a['current_address'] ?? ''));
$hasCurrentCoords = $a['current_lat'] !== null && $a['current_lng'] !== null;
if ($currentAddr !== '' && ($force || !$hasCurrentCoords)) {
$g = GeocodeService::geocode($currentAddr);
$debug['current'] = ['query' => $currentAddr, 'result' => $g];
if ($g) {
$updates['current_lat'] = $g['lat'];
$updates['current_lng'] = $g['lng'];
}
}
if (!empty($updates)) {
$set = [];
$params = [':id' => $id];
foreach ($updates as $k => $v) {
$set[] = "$k = :$k";
$params[":$k"] = $v;
}
$sql = 'UPDATE animals SET ' . implode(', ', $set) . ", updated_at=datetime('now') WHERE id=:id";
$u = $db->prepare($sql);
$u->execute($params);
}
// Si appel AJAX/JSON
$wantsJson = isset($_POST['json']) && (int) $_POST['json'] === 1;
if ($wantsJson) {
header('Content-Type: application/json; charset=utf-8');
echo json_encode(
[
'ok' => true,
'updated' => $updates,
'debug' => $debug,
],
JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES,
);
return;
}
// Sinon redirection propre vers l'onglet identité
header('Location: /animal?id=' . $id . '#tab-ident');
exit();
}
}