215 lines
7.3 KiB
PHP
215 lines
7.3 KiB
PHP
<?php
|
|
|
|
namespace App\Services\Calendar;
|
|
|
|
use App\Contracts\Calendar\CalendarClientInterface;
|
|
use App\Models\CalendarEvent;
|
|
use App\Models\CalendarReminder;
|
|
use App\Models\SourceSyncRun;
|
|
use Illuminate\Support\Carbon;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Throwable;
|
|
|
|
class CalendarEventSyncService
|
|
{
|
|
public function __construct(
|
|
private readonly CalendarClientInterface $client,
|
|
private readonly ICalendarParser $parser,
|
|
) {}
|
|
|
|
public function sync(?Carbon $from = null, ?Carbon $to = null): SourceSyncRun
|
|
{
|
|
$from ??= Carbon::now()->subDay();
|
|
$to ??= Carbon::now()->addDays((int) config('relais_sms.caldav.lookahead_days', 90));
|
|
|
|
$run = SourceSyncRun::query()->create([
|
|
'source' => 'caldav_calendar',
|
|
'status' => 'running',
|
|
'started_at' => Carbon::now(),
|
|
]);
|
|
|
|
try {
|
|
$remoteEvents = $this->client->fetchEvents($from, $to);
|
|
|
|
DB::transaction(function () use ($run, $remoteEvents, $from, $to): void {
|
|
$seenExternalIds = [];
|
|
$syncedEvents = 0;
|
|
$scheduledReminders = 0;
|
|
|
|
foreach ($remoteEvents as $remoteEvent) {
|
|
foreach ($this->parser->parse($remoteEvent->ical, $from, $to) as $parsedEvent) {
|
|
$externalId = $parsedEvent->recurrenceId === null
|
|
? $parsedEvent->uid
|
|
: 'recurrence:'.sha1($parsedEvent->uid.'|'.$parsedEvent->recurrenceId->format('Y-m-d H:i:s'));
|
|
$seenExternalIds[] = $externalId;
|
|
|
|
$event = $this->upsertEvent($externalId, $remoteEvent, $parsedEvent);
|
|
$scheduledReminders += $this->syncReminders($event, $parsedEvent);
|
|
$syncedEvents++;
|
|
}
|
|
}
|
|
|
|
$cancelledEvents = $this->cancelMissingEvents($seenExternalIds);
|
|
|
|
$run->update([
|
|
'status' => 'success',
|
|
'finished_at' => Carbon::now(),
|
|
'fetched_contacts' => count($remoteEvents),
|
|
'synced_contacts' => $syncedEvents,
|
|
'deleted_contacts' => $cancelledEvents,
|
|
'message' => $syncedEvents.' evenement(s) CalDAV synchronise(s), '.$scheduledReminders.' rappel(s) programme(s).',
|
|
]);
|
|
});
|
|
} catch (Throwable $exception) {
|
|
$run->update([
|
|
'status' => 'failed',
|
|
'finished_at' => Carbon::now(),
|
|
'error' => $exception->getMessage(),
|
|
]);
|
|
|
|
throw $exception;
|
|
}
|
|
|
|
return $run->refresh();
|
|
}
|
|
|
|
private function upsertEvent(string $externalId, CalendarRemoteEvent $remoteEvent, ParsedCalendarEvent $parsedEvent): CalendarEvent
|
|
{
|
|
$event = CalendarEvent::query()
|
|
->where('source', 'caldav')
|
|
->where('external_id', $externalId)
|
|
->first();
|
|
|
|
// Preserve the reminder history when an existing single event becomes a series.
|
|
if (! $event && $parsedEvent->recurrenceId !== null) {
|
|
$event = CalendarEvent::query()
|
|
->where('source', 'caldav')
|
|
->where('external_id', $parsedEvent->uid)
|
|
->where('starts_at', $parsedEvent->recurrenceId->format('Y-m-d H:i:s'))
|
|
->first();
|
|
}
|
|
|
|
$attributes = [
|
|
'source' => 'caldav',
|
|
'external_id' => $externalId,
|
|
'remote_url' => $remoteEvent->href,
|
|
'remote_etag' => $remoteEvent->etag,
|
|
'summary' => $parsedEvent->summary,
|
|
'description' => $parsedEvent->description,
|
|
'location' => $parsedEvent->location,
|
|
'starts_at' => $parsedEvent->startsAt,
|
|
'ends_at' => $parsedEvent->endsAt,
|
|
'attendees' => $parsedEvent->attendees,
|
|
'synced_at' => Carbon::now(),
|
|
'deleted_at' => null,
|
|
];
|
|
|
|
if (! $event) {
|
|
return CalendarEvent::query()->create($attributes);
|
|
}
|
|
|
|
$event->fill($attributes);
|
|
$event->save();
|
|
|
|
return $event;
|
|
}
|
|
|
|
private function syncReminders(CalendarEvent $event, ParsedCalendarEvent $parsedEvent): int
|
|
{
|
|
$seenReminderKeys = [];
|
|
$scheduledReminders = 0;
|
|
|
|
foreach ($parsedEvent->alarms as $index => $alarm) {
|
|
if ($alarm->action !== 'DISPLAY') {
|
|
continue;
|
|
}
|
|
|
|
$remindAt = $this->remindAt($parsedEvent, $alarm);
|
|
|
|
if ($remindAt === null) {
|
|
continue;
|
|
}
|
|
|
|
$reminderKey = sha1($alarm->action.'|'.$alarm->trigger.'|'.$index);
|
|
$seenReminderKeys[] = $reminderKey;
|
|
$reminder = CalendarReminder::query()
|
|
->where('calendar_event_id', $event->id)
|
|
->where('reminder_key', $reminderKey)
|
|
->first();
|
|
|
|
$attributes = [
|
|
'action' => $alarm->action,
|
|
'trigger_minutes' => $alarm->triggerMinutes,
|
|
'remind_at' => $remindAt,
|
|
];
|
|
|
|
if (! $reminder) {
|
|
CalendarReminder::query()->create(array_merge($attributes, [
|
|
'calendar_event_id' => $event->id,
|
|
'reminder_key' => $reminderKey,
|
|
'status' => 'pending',
|
|
]));
|
|
$scheduledReminders++;
|
|
|
|
continue;
|
|
}
|
|
|
|
if (! in_array($reminder->status, ['sent', 'sending', 'uncertain', 'skipped'], true)) {
|
|
$reminder->update(array_merge($attributes, [
|
|
'status' => 'pending',
|
|
'last_error' => null,
|
|
]));
|
|
$scheduledReminders++;
|
|
}
|
|
}
|
|
|
|
CalendarReminder::query()
|
|
->where('calendar_event_id', $event->id)
|
|
->whereNotIn('reminder_key', $seenReminderKeys ?: [''])
|
|
->whereIn('status', ['pending', 'failed'])
|
|
->update([
|
|
'status' => 'cancelled',
|
|
'last_error' => 'Rappel absent de la derniere synchronisation.',
|
|
]);
|
|
|
|
return $scheduledReminders;
|
|
}
|
|
|
|
private function remindAt(ParsedCalendarEvent $event, ParsedCalendarAlarm $alarm): ?Carbon
|
|
{
|
|
if ($alarm->absoluteRemindAt !== null) {
|
|
return $alarm->absoluteRemindAt;
|
|
}
|
|
|
|
if ($event->startsAt === null || $alarm->triggerMinutes === null) {
|
|
return null;
|
|
}
|
|
|
|
return $event->startsAt->copy()->addMinutes($alarm->triggerMinutes);
|
|
}
|
|
|
|
/**
|
|
* @param array<int, string> $seenExternalIds
|
|
*/
|
|
private function cancelMissingEvents(array $seenExternalIds): int
|
|
{
|
|
$events = CalendarEvent::query()
|
|
->where('source', 'caldav')
|
|
->whereNull('deleted_at')
|
|
->whereNotIn('external_id', $seenExternalIds ?: [''])
|
|
->get();
|
|
|
|
foreach ($events as $event) {
|
|
$event->update(['deleted_at' => Carbon::now()]);
|
|
$event->reminders()
|
|
->whereIn('status', ['pending', 'failed'])
|
|
->update([
|
|
'status' => 'cancelled',
|
|
'last_error' => 'Evenement absent de la derniere synchronisation.',
|
|
]);
|
|
}
|
|
|
|
return $events->count();
|
|
}
|
|
}
|