prepare( 'INSERT INTO private_file_access_log(user_id,resource_type,resource_id,animal_id,action,original_name,ip_address,user_agent) VALUES(?,?,?,?,?,?,?,?)', ) ->execute([ Auth::id(), $type, $id, $animalId, $action, mb_substr($name, 0, 240), $_SERVER['REMOTE_ADDR'] ?? null, mb_substr((string) ($_SERVER['HTTP_USER_AGENT'] ?? ''), 0, 500) ?: null, ]); } public static function accessLog(int $limit = 500): array { return DB::pdo() ->query( 'SELECT l.*,COALESCE(u.display_name,u.username) actor,a.name animal_name FROM private_file_access_log l LEFT JOIN users u ON u.id=l.user_id LEFT JOIN animals a ON a.id=l.animal_id ORDER BY l.id DESC LIMIT ' . max(1, min(2000, $limit)), ) ->fetchAll(PDO::FETCH_ASSOC); } public static function retentionReview(int $years): array { $rows = DB::pdo() ->query( "SELECT dc.id,dc.name,dc.email,dc.phone,dc.updated_at,group_concat(DISTINCT r.role) roles,MAX(COALESCE(ad.adoption_date,ap.event_date,dc.updated_at)) last_activity,(SELECT COUNT(*) FROM animal_placements active JOIN animals a ON a.id=active.animal_id WHERE active.contact_id=dc.id AND active.event_type='foster' AND a.deleted_at IS NULL AND a.archived_at IS NULL AND lower(a.status)='fa') active_links FROM directory_contacts dc LEFT JOIN directory_contact_roles r ON r.contact_id=dc.id LEFT JOIN adoptions ad ON ad.adopter_contact_id=dc.id LEFT JOIN animal_placements ap ON ap.contact_id=dc.id WHERE dc.deleted_at IS NULL AND dc.kind='person' AND dc.anonymized_at IS NULL GROUP BY dc.id ORDER BY date(last_activity),dc.name COLLATE NOCASE", ) ->fetchAll(PDO::FETCH_ASSOC); $out = []; foreach ($rows as $row) { $roles = array_filter(explode(',', (string) $row['roles'])); $limits = []; foreach ($roles as $role) { $limits[] = match ($role) { 'adoptant' => AppSettings::int('privacy_retention_adopter_years', 1, 30), 'fa' => AppSettings::int('privacy_retention_foster_years', 1, 30), 'benevole' => AppSettings::int('privacy_retention_volunteer_years', 1, 30), default => $years, }; } $limit = $limits ? max($limits) : $years; $last = strtotime((string) $row['last_activity']) ?: time(); if ($last > strtotime('-' . $limit . ' years')) { continue; } $row['policy_years'] = $limit; $row['blocked'] = (int) $row['active_links'] > 0; $out[] = $row; } return $out; } public static function exportContact(int $id): string { $db = DB::pdo(); $s = $db->prepare( "SELECT dc.*,group_concat(r.role,',') roles,org.name organization_name FROM directory_contacts dc LEFT JOIN directory_contact_roles r ON r.contact_id=dc.id LEFT JOIN directory_contacts org ON org.id=dc.organization_id WHERE dc.id=? AND dc.deleted_at IS NULL GROUP BY dc.id", ); $s->execute([$id]); $contact = $s->fetch(PDO::FETCH_ASSOC); if (!$contact) { throw new RuntimeException(t('privacy.contact_not_found')); } $queries = [ 'adoptions' => 'SELECT a.internal_code,a.name animal,ad.adoption_date,ad.notes FROM adoptions ad JOIN animals a ON a.id=ad.animal_id WHERE ad.adopter_contact_id=? ORDER BY ad.adoption_date', 'placements' => 'SELECT a.internal_code,a.name animal,ap.event_type,ap.event_date,ap.reason,ap.notes FROM animal_placements ap JOIN animals a ON a.id=ap.animal_id WHERE ap.contact_id=? ORDER BY ap.event_date,ap.id', 'deposited_animals' => 'SELECT internal_code,name,intake_date,intake_type,intake_reason,intake_circumstances FROM animals WHERE depositor_contact_id=? ORDER BY intake_date', ]; $payload = ['generated_at' => (new DateTimeImmutable())->format(DATE_ATOM), 'contact' => $contact]; foreach ($queries as $key => $sql) { $q = $db->prepare($sql); $q->execute([$id]); $payload[$key] = $q->fetchAll(PDO::FETCH_ASSOC); } $tmp = tempnam(sys_get_temp_dir(), 'globinours-contact-'); if ($tmp === false) { throw new RuntimeException(t('privacy.export_failed')); } @unlink($tmp); $path = $tmp . '.zip'; $zip = new ZipArchive(); if ($zip->open($path, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) { throw new RuntimeException(t('privacy.export_failed')); } $zip->addFromString( 'contact.json', json_encode($payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), ); foreach (['adoptions', 'placements', 'deposited_animals'] as $key) { $zip->addFromString($key . '.csv', self::csv($payload[$key])); } $zip->addFromString( 'README.txt', "Export de portabilité Globinours\nGénéré le " . date('d/m/Y H:i') . "\nDonnées à conserver dans un emplacement sécurisé.\n", ); $zip->close(); return $path; } public static function anonymize(int $id, string $reason): void { $reason = trim($reason); if ($reason === '') { throw new RuntimeException(t('privacy.reason_required')); } $db = DB::pdo(); $s = $db->prepare( "SELECT * FROM directory_contacts WHERE id=? AND deleted_at IS NULL AND kind='person' AND anonymized_at IS NULL", ); $s->execute([$id]); $contact = $s->fetch(PDO::FETCH_ASSOC); if (!$contact) { throw new RuntimeException(t('privacy.contact_not_found')); } $backup = BackupService::create('avant-anonymisation-contact-' . $id); $label = t('privacy.anonymous_contact') . ' #' . $id; $db->beginTransaction(); try { $db->prepare( "UPDATE directory_contacts SET name=?,phone=NULL,email=NULL,address=NULL,postal_code=NULL,city=NULL,country='France',organization_id=NULL,notes=NULL,anonymized_at=datetime('now'),anonymization_reason=?,updated_at=datetime('now') WHERE id=?", )->execute([$label, $reason, $id]); $db->prepare( 'UPDATE adoptions SET adopter_name=?,adopter_phone=NULL,adopter_email=NULL,adopter_address=NULL,adopter_postal_code=NULL,adopter_city=NULL WHERE adopter_contact_id=?', )->execute([$label, $id]); foreach ([(string) $contact['name']] as $old) { $db->prepare( 'UPDATE animal_movements SET contact_name=? WHERE lower(trim(contact_name))=lower(trim(?))', )->execute([$label, $old]); } $db->commit(); AuditService::log( 'contact_anonymized', '/settings/privacy/anonymize', 'Contact anonymisé', 'contact', $id, ['reason' => $reason, 'backup' => $backup['name'] ?? null], ); } catch (Throwable $e) { if ($db->inTransaction()) { $db->rollBack(); } throw $e; } } public static function maintenance(): array { $months = AppSettings::int('privacy_access_log_months', 1, 120); $s = DB::pdo()->prepare("DELETE FROM private_file_access_log WHERE datetime(accessed_at)execute(['-' . $months . ' months']); return ['access_logs_removed' => $s->rowCount(), 'retention_months' => $months]; } private static function csv(array $rows): string { if (!$rows) { return "\xEF\xBB\xBF"; } $stream = fopen('php://temp', 'r+'); fwrite($stream, "\xEF\xBB\xBF"); fputcsv($stream, array_keys($rows[0]), ';'); foreach ($rows as $row) { fputcsv($stream, array_values($row), ';'); } rewind($stream); $out = stream_get_contents($stream); fclose($stream); return (string) $out; } }