prepare('SELECT agenda_feed_token_hash,agenda_feed_created_at FROM users WHERE id=?'); $feed->execute([(int) $user['id']]); $feedState = $feed->fetch(PDO::FETCH_ASSOC) ?: []; $token = (string) ($_SESSION['agenda_feed_token_once'] ?? ''); unset($_SESSION['agenda_feed_token_once']); $volunteers = $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='benevole' WHERE dc.deleted_at IS NULL ORDER BY dc.name COLLATE NOCASE", ) ->fetchAll(PDO::FETCH_ASSOC); $edit = (int) ($_GET['edit'] ?? 0); render('agenda.php', [ 'title' => t('agenda.title'), 'cursor' => $cursor, 'weekCursor' => $weekCursor, 'viewMode' => $viewMode, 'items' => $items, 'upcoming' => $upcoming, 'filters' => $filters, 'filterSuffix' => self::filterSuffix($filters), 'feedEnabled' => !empty($feedState['agenda_feed_token_hash']), 'feedCreatedAt' => $feedState['agenda_feed_created_at'] ?? null, 'feedUrl' => $token !== '' ? CalendarFeedService::absoluteUrl('/agenda/feed.ics?token=' . $token) : '', 'editItem' => $edit ? AgendaService::find($edit) : null, 'animals' => $db ->query( 'SELECT id,name FROM animals WHERE deleted_at IS NULL AND archived_at IS NULL ORDER BY name COLLATE NOCASE', ) ->fetchAll(), 'contacts' => $db ->query('SELECT id,name FROM directory_contacts WHERE deleted_at IS NULL ORDER BY name COLLATE NOCASE') ->fetchAll(), 'volunteers' => $volunteers, 'users' => $db ->query( "SELECT id,COALESCE(NULLIF(display_name,''),username) name FROM users WHERE active=1 ORDER BY name COLLATE NOCASE", ) ->fetchAll(), 'status' => (string) ($_GET['status'] ?? ''), 'error' => (string) ($_GET['error'] ?? ''), ]); } public static function save(): void { try { $id = (int) ($_POST['id'] ?? 0); $saved = AgendaService::save($_POST, $id ?: null); $conflicts = AgendaService::conflicts($saved); AuditService::log( $id ? 'agenda_updated' : 'agenda_created', '/agenda/save', $id ? 'Événement modifié' : 'Événement créé', 'agenda', $saved, ['conflicts' => count($conflicts)], ); header('Location: /agenda?status=' . ($conflicts ? 'conflict' : 'saved')); } catch (Throwable $e) { header('Location: /agenda?error=' . rawurlencode(SecurityService::publicError($e))); } exit(); } public static function status(): void { try { $id = (int) ($_POST['id'] ?? 0); AgendaService::setStatus($id, (string) ($_POST['status'] ?? '')); AuditService::log('agenda_status', '/agenda/status', 'État de l’agenda modifié', 'agenda', $id, [ 'status' => $_POST['status'] ?? '', ]); header('Location: /agenda?status=updated'); } catch (Throwable $e) { header('Location: /agenda?error=' . rawurlencode(SecurityService::publicError($e))); } exit(); } public static function export(): void { $view = (string) ($_GET['view'] ?? 'month'); if ($view === 'week') { $date = self::date((string) ($_GET['date'] ?? '')) ?: new DateTimeImmutable('today'); $items = AgendaService::week($date); $name = 'agenda-semaine-' . $date->modify('monday this week')->format('Y-m-d') . '.ics'; } else { $month = (string) ($_GET['month'] ?? date('Y-m')); $date = preg_match('/^\d{4}-\d{2}$/', $month) ? DateTimeImmutable::createFromFormat('!Y-m', $month) : false; $date = $date ?: new DateTimeImmutable('first day of this month'); $items = AgendaService::month($date); $name = 'agenda-' . $date->format('Y-m') . '.ics'; } $items = self::filtered($items, self::filters(), (int) Auth::id()); self::output( CalendarFeedService::render($items, AppSettings::get('association_name') . ' — ' . t('agenda.title')), $name, ); } public static function feed(): void { $user = CalendarFeedService::userForToken((string) ($_GET['token'] ?? '')); if (!$user) { http_response_code(404); header('Content-Type: text/plain; charset=utf-8'); echo t('agenda.feed_invalid'); return; } $items = AgendaService::range(new DateTimeImmutable('-90 days'), new DateTimeImmutable('+365 days 23:59:59')); self::output( CalendarFeedService::render($items, AppSettings::get('association_name') . ' — ' . t('agenda.title')), 'globinours-agenda.ics', false, ); } public static function regenerateFeed(): void { self::feedPost(); $_SESSION['agenda_feed_token_once'] = CalendarFeedService::generateToken((int) Auth::id()); AuditService::log('agenda_feed_regenerated', '/agenda/feed/regenerate', 'Lien privé d’agenda régénéré'); header('Location: /agenda?status=feed'); exit(); } public static function revokeFeed(): void { self::feedPost(); CalendarFeedService::revoke((int) Auth::id()); AuditService::log('agenda_feed_revoked', '/agenda/feed/revoke', 'Lien privé d’agenda révoqué'); header('Location: /agenda?status=feed-revoked'); exit(); } private static function filters(): array { $types = [...AgendaService::TYPES, 'vaccine', 'deworming', 'treatment_start', 'treatment_end']; $scope = ($_GET['scope'] ?? 'all') === 'mine' ? 'mine' : 'all'; $type = in_array($t = (string) ($_GET['category'] ?? ''), $types, true) ? $t : ''; return [ 'scope' => $scope, 'assigned_user_id' => max(0, (int) ($_GET['assigned_user_id'] ?? 0)), 'volunteer_contact_id' => max(0, (int) ($_GET['volunteer_contact_id'] ?? 0)), 'category' => $type, 'show_medical' => (string) ($_GET['show_medical'] ?? '1') !== '0', 'q' => mb_substr(trim((string) ($_GET['q'] ?? '')), 0, 100), ]; } private static function filtered(array $items, array $f, int $userId): array { return array_values( array_filter($items, static function ($row) use ($f, $userId): bool { $source = (string) ($row['source_type'] ?? 'agenda'); if ($f['scope'] === 'mine' && (int) ($row['assigned_user_id'] ?? 0) !== $userId) { return false; } if ($f['assigned_user_id'] && (int) ($row['assigned_user_id'] ?? 0) !== $f['assigned_user_id']) { return false; } if ( $f['volunteer_contact_id'] && !in_array( $f['volunteer_contact_id'], array_map('intval', array_filter(explode(',', (string) ($row['volunteer_ids'] ?? '')))), true, ) ) { return false; } if ($f['category'] !== '' && (string) $row['item_type'] !== $f['category']) { return false; } if (!$f['show_medical'] && $source !== 'agenda') { return false; } if ( $f['q'] !== '' && !str_contains( mb_strtolower( implode(' ', [ (string) ($row['title'] ?? ''), (string) ($row['animal_name'] ?? ''), (string) ($row['location'] ?? ''), (string) ($row['assigned_name'] ?? ''), (string) ($row['volunteer_names'] ?? ''), ]), ), mb_strtolower($f['q']), ) ) { return false; } return true; }), ); } private static function filterSuffix(array $f): string { $query = http_build_query( [ 'scope' => $f['scope'], 'assigned_user_id' => $f['assigned_user_id'] ?: null, 'volunteer_contact_id' => $f['volunteer_contact_id'] ?: null, 'category' => $f['category'] ?: null, 'show_medical' => $f['show_medical'] ? '1' : '0', 'q' => $f['q'] ?: null, ], '', '&', PHP_QUERY_RFC3986, ); return $query !== '' ? '&' . str_replace('&', '&', $query) : ''; } private static function feedPost(): void { if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST' || !Auth::validCsrf($_POST['csrf'] ?? null)) { http_response_code(419); exit(); } if (!Auth::user() || !PermissionService::can('agenda')) { http_response_code(403); exit(); } } private static function output(string $content, string $filename, bool $attachment = true): void { header('Content-Type: text/calendar; charset=utf-8'); header('Content-Disposition: ' . ($attachment ? 'attachment' : 'inline') . '; filename="' . $filename . '"'); header('Cache-Control: private, no-store'); echo $content; exit(); } private static function date(string $value): ?DateTimeImmutable { $date = DateTimeImmutable::createFromFormat('!Y-m-d', $value); return $date && $date->format('Y-m-d') === $value ? $date : null; } }