$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(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)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; } }