Globinours/app/Services/AgendaService.php

351 lines
18 KiB
PHP

<?php
declare(strict_types=1);
final class AgendaService
{
public const TYPES = ['appointment', 'veterinary', 'transport', 'pre_adoption', 'task', 'other'];
public static function save(array $data, ?int $id = null): int
{
$type = (string) ($data['item_type'] ?? 'task');
$title = trim((string) ($data['title'] ?? ''));
$date = trim((string) ($data['date'] ?? ''));
$time = trim((string) ($data['time'] ?? ''));
$allDay = isset($data['all_day']);
if (!in_array($type, self::TYPES, true) || $title === '' || !self::date($date)) {
throw new RuntimeException(t('agenda.invalid'));
}
if (!$allDay && !preg_match('/^([01]\d|2[0-3]):[0-5]\d$/', $time)) {
throw new RuntimeException(t('agenda.invalid_time'));
}
$start = $date . ' ' . ($allDay ? '00:00:00' : $time . ':00');
$endDate = trim((string) ($data['end_date'] ?? ''));
$endTime = trim((string) ($data['end_time'] ?? ''));
$end = null;
if ($endDate !== '' || $endTime !== '') {
if (!self::date($endDate) || !preg_match('/^([01]\d|2[0-3]):[0-5]\d$/', $endTime)) {
throw new RuntimeException(t('agenda.invalid_end'));
}
$end = $endDate . ' ' . $endTime . ':00';
if ($end < $start) {
throw new RuntimeException(t('agenda.end_before_start'));
}
}
$repeat = (string) ($data['repeat_rule'] ?? 'none');
if (!in_array($repeat, ['none', 'daily', 'weekly', 'monthly'], true)) {
$repeat = 'none';
}
$until = trim((string) ($data['repeat_until'] ?? ''));
if ($until !== '' && !self::date($until)) {
throw new RuntimeException(t('agenda.invalid_repeat'));
}
$values = [
':type' => $type,
':title' => mb_substr($title, 0, 180),
':description' => trim((string) ($data['description'] ?? '')) ?: null,
':start' => $start,
':end' => $end,
':all_day' => $allDay ? 1 : 0,
':location' => trim((string) ($data['location'] ?? '')) ?: null,
':priority' => in_array(
$p = (string) ($data['priority'] ?? 'normal'),
['low', 'normal', 'high', 'urgent'],
true,
)
? $p
: 'normal',
':assigned' => (int) ($data['assigned_user_id'] ?? 0) ?: null,
':animal' => (int) ($data['animal_id'] ?? 0) ?: null,
':contact' => (int) ($data['contact_id'] ?? 0) ?: null,
':reminder' => ($data['reminder_minutes'] ?? '') === '' ? null : max(0, (int) $data['reminder_minutes']),
':repeat' => $repeat,
':until' => $until ?: null,
':user' => Auth::id(),
];
$db = DB::pdo();
$db->beginTransaction();
try {
if ($id) {
$values[':id'] = $id;
$db->prepare(
"UPDATE agenda_items SET item_type=:type,title=:title,description=:description,starts_at=:start,ends_at=:end,all_day=:all_day,location=:location,priority=:priority,assigned_user_id=:assigned,animal_id=:animal,contact_id=:contact,reminder_minutes=:reminder,repeat_rule=:repeat,repeat_until=:until,updated_at=datetime('now') WHERE id=:id",
)->execute($values);
} else {
$db->prepare(
'INSERT INTO agenda_items(item_type,title,description,starts_at,ends_at,all_day,location,priority,assigned_user_id,animal_id,contact_id,reminder_minutes,repeat_rule,repeat_until,created_by) VALUES(:type,:title,:description,:start,:end,:all_day,:location,:priority,:assigned,:animal,:contact,:reminder,:repeat,:until,:user)',
)->execute($values);
$id = (int) $db->lastInsertId();
}
self::saveVolunteers($id, $data['volunteer_contact_ids'] ?? []);
$db->commit();
return $id;
} catch (Throwable $e) {
if ($db->inTransaction()) {
$db->rollBack();
}
throw $e;
}
}
public static function find(int $id): ?array
{
$s = DB::pdo()->prepare(
"SELECT ai.*,(SELECT group_concat(contact_id,',') FROM agenda_volunteers WHERE agenda_item_id=ai.id) volunteer_ids FROM agenda_items ai WHERE ai.id=?",
);
$s->execute([$id]);
return $s->fetch(PDO::FETCH_ASSOC) ?: null;
}
public static function setStatus(int $id, string $status): void
{
if (!in_array($status, ['planned', 'completed', 'cancelled'], true)) {
throw new RuntimeException(t('agenda.invalid'));
}
DB::pdo()
->prepare(
"UPDATE agenda_items SET status=?,completed_at=CASE WHEN ?='completed' THEN datetime('now') ELSE NULL END,completed_by=CASE WHEN ?='completed' THEN ? ELSE NULL END,updated_at=datetime('now') WHERE id=?",
)
->execute([$status, $status, $status, Auth::id(), $id]);
}
public static function conflicts(int $id): array
{
$db = DB::pdo();
$s = $db->prepare(
"SELECT DISTINCT other.id,other.title,other.starts_at FROM agenda_items current JOIN agenda_items other ON other.id<>current.id AND other.status='planned' AND datetime(other.starts_at)<datetime(COALESCE(current.ends_at,CASE WHEN current.all_day=1 THEN datetime(current.starts_at,'+1 day') ELSE datetime(current.starts_at,'+1 hour') END)) AND datetime(COALESCE(other.ends_at,CASE WHEN other.all_day=1 THEN datetime(other.starts_at,'+1 day') ELSE datetime(other.starts_at,'+1 hour') END))>datetime(current.starts_at) WHERE current.id=:id AND current.status='planned' AND ((current.assigned_user_id IS NOT NULL AND other.assigned_user_id=current.assigned_user_id) OR EXISTS(SELECT 1 FROM agenda_volunteers mine JOIN agenda_volunteers theirs ON theirs.contact_id=mine.contact_id AND theirs.agenda_item_id=other.id WHERE mine.agenda_item_id=current.id)) ORDER BY datetime(other.starts_at)",
);
$s->execute([':id' => $id]);
return $s->fetchAll(PDO::FETCH_ASSOC);
}
public static function month(DateTimeImmutable $month): array
{
return self::between(
$month->modify('first day of this month')->setTime(0, 0),
$month->modify('last day of this month')->setTime(23, 59, 59),
);
}
public static function week(DateTimeImmutable $date): array
{
$start = $date->modify('monday this week')->setTime(0, 0);
return self::between($start, $start->modify('+6 days')->setTime(23, 59, 59));
}
public static function range(DateTimeImmutable $from, DateTimeImmutable $to): array
{
return self::between($from, $to);
}
public static function upcoming(int $days = 30): array
{
return self::between(new DateTimeImmutable('today'), new DateTimeImmutable('+' . $days . ' days 23:59:59'));
}
public static function due(): array
{
$db = DB::pdo();
$overdue = $db
->query(
"SELECT ai.*,ai.starts_at instance_starts_at,a.name animal_name,COALESCE(u.display_name,u.username) assigned_name FROM agenda_items ai LEFT JOIN animals a ON a.id=ai.animal_id LEFT JOIN users u ON u.id=ai.assigned_user_id WHERE ai.status='planned' AND ai.repeat_rule='none' AND datetime(ai.starts_at)<datetime('now') ORDER BY datetime(ai.starts_at) LIMIT 25",
)
->fetchAll(PDO::FETCH_ASSOC);
$soon = array_values(
array_filter(
self::upcoming(2),
static fn($row) => $row['status'] === 'planned' &&
strtotime($row['instance_starts_at']) <= strtotime('+1 day'),
),
);
$seen = [];
$out = [];
foreach ([...$overdue, ...$soon] as $row) {
$key = (int) $row['id'] . '|' . ($row['instance_starts_at'] ?? $row['starts_at']);
if (isset($seen[$key])) {
continue;
}
$seen[$key] = true;
$out[] = $row;
}
usort(
$out,
static fn($a, $b) => strcmp(
(string) ($a['instance_starts_at'] ?? $a['starts_at']),
(string) ($b['instance_starts_at'] ?? $b['starts_at']),
),
);
return array_slice($out, 0, 25);
}
private static function between(DateTimeImmutable $from, DateTimeImmutable $to): array
{
$db = DB::pdo();
$s = $db->prepare(
"SELECT ai.*,a.name animal_name,dc.name contact_name,COALESCE(u.display_name,u.username) assigned_name,(SELECT group_concat(vdc.name,', ') FROM agenda_volunteers av JOIN directory_contacts vdc ON vdc.id=av.contact_id WHERE av.agenda_item_id=ai.id) volunteer_names,(SELECT group_concat(av.contact_id,',') FROM agenda_volunteers av WHERE av.agenda_item_id=ai.id) volunteer_ids FROM agenda_items ai LEFT JOIN animals a ON a.id=ai.animal_id LEFT JOIN directory_contacts dc ON dc.id=ai.contact_id LEFT JOIN users u ON u.id=ai.assigned_user_id WHERE date(ai.starts_at)<=:to AND (ai.repeat_rule<>'none' OR datetime(ai.starts_at)>=:from) AND ai.status<>'cancelled' ORDER BY datetime(ai.starts_at),ai.priority DESC",
);
$s->execute([':from' => $from->format('Y-m-d H:i:s'), ':to' => $to->format('Y-m-d')]);
$out = [];
foreach ($s->fetchAll(PDO::FETCH_ASSOC) as $row) {
$start = new DateTimeImmutable($row['starts_at']);
$end = $row['ends_at'] ? new DateTimeImmutable($row['ends_at']) : null;
$step = match ($row['repeat_rule']) {
'daily' => '+1 day',
'weekly' => '+1 week',
'monthly' => '+1 month',
default => null,
};
while ($start < $from && $step) {
$start = $start->modify($step);
}
do {
if ($start > $to) {
break;
}
if (!$row['repeat_until'] || $start->format('Y-m-d') <= $row['repeat_until']) {
$instance = $row;
$instance['instance_starts_at'] = $start->format('Y-m-d H:i:s');
$instance['instance_ends_at'] = $end
? $end
->modify(
$start->getTimestamp() -
(new DateTimeImmutable($row['starts_at']))->getTimestamp() .
' seconds',
)
->format('Y-m-d H:i:s')
: null;
$instance['source_type'] = 'agenda';
if (!empty($instance['volunteer_names'])) {
$instance['title'] .= ' · 👥 ' . $instance['volunteer_names'];
}
$out[] = $instance;
}
if (!$step) {
break;
}
$start = $start->modify($step);
} while (!$row['repeat_until'] || $start->format('Y-m-d') <= $row['repeat_until']);
}
$vaccines = $db->prepare(
"SELECT v.id,v.due_date,rv.name vaccine_name,a.id animal_id,a.name animal_name FROM vaccinations v JOIN ref_vaccines rv ON rv.id=v.vaccine_id JOIN animals a ON a.id=v.animal_id WHERE v.due_date IS NOT NULL AND date(v.due_date) BETWEEN :from AND :to AND a.deleted_at IS NULL AND a.archived_at IS NULL AND lower(a.status) NOT IN ('adopte','adopté','decede','décédé') AND NOT EXISTS(SELECT 1 FROM vaccinations newer WHERE newer.animal_id=v.animal_id AND newer.vaccine_id=v.vaccine_id AND date(newer.done_date)>date(v.done_date)) ORDER BY date(v.due_date),a.name COLLATE NOCASE",
);
$vaccines->execute([':from' => $from->format('Y-m-d'), ':to' => $to->format('Y-m-d')]);
foreach ($vaccines->fetchAll(PDO::FETCH_ASSOC) as $v) {
$out[] = self::reminder(
-(100000000 + (int) $v['id']),
(int) $v['id'],
'vaccine',
'vaccine',
t('agenda.vaccine_reminder', ['vaccine' => $v['vaccine_name']]),
$v['due_date'],
(int) $v['animal_id'],
$v['animal_name'],
'high',
);
}
$dewormings = $db->prepare(
"SELECT d.id,d.next_due_date,COALESCE(rd.name,'') dewormer_name,a.id animal_id,a.name animal_name FROM dewormings d LEFT JOIN ref_dewormers rd ON rd.id=d.dewormer_id JOIN animals a ON a.id=d.animal_id WHERE d.next_due_date IS NOT NULL AND date(d.next_due_date) BETWEEN :from AND :to AND a.deleted_at IS NULL AND a.archived_at IS NULL AND lower(a.status) NOT IN ('adopte','adopté','decede','décédé') AND NOT EXISTS(SELECT 1 FROM dewormings newer WHERE newer.animal_id=d.animal_id AND (date(newer.administered_on)>date(d.administered_on) OR (date(newer.administered_on)=date(d.administered_on) AND newer.id>d.id))) ORDER BY date(d.next_due_date),a.name COLLATE NOCASE",
);
$dewormings->execute([':from' => $from->format('Y-m-d'), ':to' => $to->format('Y-m-d')]);
foreach ($dewormings->fetchAll(PDO::FETCH_ASSOC) as $d) {
$out[] = self::reminder(
-(200000000 + (int) $d['id']),
(int) $d['id'],
'deworming',
'deworming',
t('agenda.deworming_reminder', ['product' => $d['dewormer_name'] ?: t('agenda.deworming')]),
$d['next_due_date'],
(int) $d['animal_id'],
$d['animal_name'],
'high',
);
}
$treatments = $db->prepare(
"SELECT t.id,t.start_date,t.end_date,m.name medication_name,a.id animal_id,a.name animal_name FROM treatments t JOIN ref_medications m ON m.id=t.medication_id JOIN animals a ON a.id=t.animal_id WHERE t.ongoing=1 AND (date(t.start_date) BETWEEN :from AND :to OR (t.end_date IS NOT NULL AND date(t.end_date) BETWEEN :from AND :to)) AND a.deleted_at IS NULL AND a.archived_at IS NULL AND lower(a.status) NOT IN ('adopte','adopté','decede','décédé') ORDER BY date(t.start_date),a.name COLLATE NOCASE",
);
$treatments->execute([':from' => $from->format('Y-m-d'), ':to' => $to->format('Y-m-d')]);
foreach ($treatments->fetchAll(PDO::FETCH_ASSOC) as $t) {
if ($t['start_date'] >= $from->format('Y-m-d') && $t['start_date'] <= $to->format('Y-m-d')) {
$out[] = self::reminder(
-(300000000 + (int) $t['id']),
(int) $t['id'],
'treatment_start',
'treatment_start',
t('agenda.treatment_start', ['medication' => $t['medication_name']]),
$t['start_date'],
(int) $t['animal_id'],
$t['animal_name'],
'high',
);
}
if ($t['end_date'] && $t['end_date'] >= $from->format('Y-m-d') && $t['end_date'] <= $to->format('Y-m-d')) {
$out[] = self::reminder(
-(400000000 + (int) $t['id']),
(int) $t['id'],
'treatment_end',
'treatment_end',
t('agenda.treatment_end', ['medication' => $t['medication_name']]),
$t['end_date'],
(int) $t['animal_id'],
$t['animal_name'],
'high',
);
}
}
usort($out, static fn($a, $b) => strcmp($a['instance_starts_at'], $b['instance_starts_at']));
return $out;
}
private static function reminder(
int $id,
int $sourceId,
string $sourceType,
string $itemType,
string $title,
string $date,
int $animalId,
string $animalName,
string $priority,
): array {
return [
'id' => $id,
'source_id' => $sourceId,
'source_type' => $sourceType,
'item_type' => $itemType,
'title' => $title,
'description' => null,
'starts_at' => $date . ' 00:00:00',
'instance_starts_at' => $date . ' 00:00:00',
'instance_ends_at' => null,
'all_day' => 1,
'location' => null,
'status' => 'planned',
'priority' => $priority,
'assigned_user_id' => null,
'assigned_name' => null,
'volunteer_names' => null,
'volunteer_ids' => null,
'animal_id' => $animalId,
'animal_name' => $animalName,
'contact_id' => null,
'contact_name' => null,
'reminder_minutes' => null,
'repeat_rule' => 'none',
'repeat_until' => null,
];
}
private static function saveVolunteers(int $itemId, mixed $input): void
{
$ids = is_array($input) ? array_values(array_unique(array_filter(array_map('intval', $input)))) : [];
$db = DB::pdo();
$db->prepare('DELETE FROM agenda_volunteers WHERE agenda_item_id=?')->execute([$itemId]);
if (!$ids) {
return;
}
$check = $db->prepare(
"SELECT 1 FROM directory_contacts dc JOIN directory_contact_roles r ON r.contact_id=dc.id AND r.role='benevole' WHERE dc.id=? AND dc.deleted_at IS NULL",
);
$insert = $db->prepare('INSERT INTO agenda_volunteers(agenda_item_id,contact_id) VALUES(?,?)');
foreach ($ids as $id) {
$check->execute([$id]);
if ($check->fetchColumn()) {
$insert->execute([$itemId, $id]);
}
}
}
private static function date(string $v): bool
{
$d = DateTimeImmutable::createFromFormat('!Y-m-d', $v);
return $d && $d->format('Y-m-d') === $v;
}
}