267 lines
11 KiB
PHP
267 lines
11 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
final class AgendaController
|
||
{
|
||
public static function index(): void
|
||
{
|
||
$month = (string) ($_GET['month'] ?? date('Y-m'));
|
||
if (!preg_match('/^\d{4}-\d{2}$/', $month)) {
|
||
$month = date('Y-m');
|
||
}
|
||
$cursor =
|
||
DateTimeImmutable::createFromFormat('!Y-m', $month) ?: new DateTimeImmutable('first day of this month');
|
||
$date = (string) ($_GET['date'] ?? date('Y-m-d'));
|
||
$weekCursor = self::date($date) ?: new DateTimeImmutable('today');
|
||
$db = DB::pdo();
|
||
$user = Auth::user();
|
||
$requestedView = (string) ($_GET['view'] ?? '');
|
||
$viewMode = in_array($requestedView, ['month', 'week'], true)
|
||
? $requestedView
|
||
: (string) ($_SESSION['agenda_view'] ?? ($user['agenda_view'] ?? 'month'));
|
||
if (!in_array($viewMode, ['month', 'week'], true)) {
|
||
$viewMode = 'month';
|
||
}
|
||
if ($requestedView !== '') {
|
||
$_SESSION['agenda_view'] = $viewMode;
|
||
}
|
||
$filters = self::filters();
|
||
$items = self::filtered(
|
||
$viewMode === 'week' ? AgendaService::week($weekCursor) : AgendaService::month($cursor),
|
||
$filters,
|
||
(int) $user['id'],
|
||
);
|
||
$upcoming = self::filtered(AgendaService::upcoming(), $filters, (int) $user['id']);
|
||
$feed = $db->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;
|
||
}
|
||
}
|