From 9cdfc331b05c7a92436e7ed919faf0b3824b0c8f Mon Sep 17 00:00:00 2001 From: isaacclad Date: Sat, 4 Jul 2026 11:48:37 +0000 Subject: [PATCH] Sync Ladill Events to the Mail utility calendar. Events upsert calendar entries by stable external_ref keys, prefer the owner's linked mailbox, and Meet skips mail sync for events-sourced rooms so linked virtual sessions appear only once on the calendar. Co-authored-by: Cursor --- .env.example | 4 + .../Events/EventMailCalendarSyncService.php | 203 ++++++++++++++++++ .../Integrations/MailCalendarClient.php | 81 +++++++ app/Services/Qr/QrCodeManagerService.php | 3 + config/mail_calendar.php | 6 + storage/app/private/qr/1/codes/1/qr.png | Bin 0 -> 1473 bytes storage/app/private/qr/1/codes/1/qr.svg | 26 +++ storage/app/private/qr/1/codes/2/qr.png | Bin 0 -> 1475 bytes storage/app/private/qr/1/codes/2/qr.svg | 26 +++ tests/Feature/EventMailCalendarSyncTest.php | 126 +++++++++++ 10 files changed, 475 insertions(+) create mode 100644 app/Services/Events/EventMailCalendarSyncService.php create mode 100644 app/Services/Integrations/MailCalendarClient.php create mode 100644 config/mail_calendar.php create mode 100644 storage/app/private/qr/1/codes/1/qr.png create mode 100644 storage/app/private/qr/1/codes/1/qr.svg create mode 100644 storage/app/private/qr/1/codes/2/qr.png create mode 100644 storage/app/private/qr/1/codes/2/qr.svg create mode 100644 tests/Feature/EventMailCalendarSyncTest.php diff --git a/.env.example b/.env.example index 10d4208..8aefc8e 100644 --- a/.env.example +++ b/.env.example @@ -43,6 +43,10 @@ MEET_API_URL=https://meet.ladill.com/api/service/v1 MEET_API_KEY_EVENTS= MEET_WEBHOOK_SECRET_EVENTS= +# Ladill Mail calendar (sync events to the webmail utility calendar) +LADILL_MAIL_API_URL=https://mail.ladill.com/api/service/v1 +MAIL_API_KEY_EVENTS= + # Inbound service API (Meet calls Events) EVENTS_API_KEY_MEET= diff --git a/app/Services/Events/EventMailCalendarSyncService.php b/app/Services/Events/EventMailCalendarSyncService.php new file mode 100644 index 0000000..3e41dfe --- /dev/null +++ b/app/Services/Events/EventMailCalendarSyncService.php @@ -0,0 +1,203 @@ +type !== QrCode::TYPE_EVENT || ! $this->mail->isConfigured()) { + return $event; + } + + $mailbox = $this->resolveMailbox($owner); + if ($mailbox === null) { + return $event; + } + + $entries = $this->entriesFor($event); + $previous = (array) data_get($event->payload, 'calendar_sync.items', []); + $synced = []; + + foreach ($entries as $entry) { + $result = $this->mail->upsertEvent(array_merge($entry, [ + 'mailbox_email' => $mailbox, + ])); + + if ($result && isset($result['id'])) { + $synced[$entry['external_ref']] = (int) $result['id']; + } + } + + foreach ($previous as $externalRef => $eventId) { + if (! isset($synced[$externalRef]) && is_numeric($eventId)) { + $this->mail->deleteEvent((int) $eventId, $mailbox, (string) $externalRef); + } + } + + if ($entries === [] && $previous !== []) { + foreach ($previous as $externalRef => $eventId) { + if (is_numeric($eventId)) { + $this->mail->deleteEvent((int) $eventId, $mailbox, (string) $externalRef); + } + } + } + + $payload = (array) ($event->payload ?? []); + $payload['calendar_sync'] = ['items' => $synced]; + $event->payload = $payload; + $event->save(); + + Log::info('Events mail calendar synced', [ + 'event_id' => $event->id, + 'entries' => count($synced), + ]); + + return $event->fresh(); + } + + /** + * @return list> + */ + public function entriesFor(QrCode $event): array + { + $content = $event->content(); + $format = (string) ($content['format'] ?? 'in_person'); + $sessions = array_values(array_filter( + (array) ($content['virtual_sessions'] ?? []), + fn ($session) => is_array($session) && trim((string) ($session['title'] ?? '')) !== '', + )); + + $entries = []; + + if (in_array($format, ['virtual', 'hybrid'], true) && $sessions !== []) { + foreach ($sessions as $session) { + $entry = $this->sessionEntry($event, $content, $session); + if ($entry !== null) { + $entries[] = $entry; + } + } + + if ($format === 'hybrid' && filled($content['starts_at'] ?? null)) { + $entries[] = $this->mainEventEntry($event, $content, ' (in person)'); + } + + return $entries; + } + + if (filled($content['starts_at'] ?? null)) { + $entries[] = $this->mainEventEntry($event, $content); + } + + return $entries; + } + + /** + * @param array $content + * @param array $session + * @return array|null + */ + private function sessionEntry(QrCode $event, array $content, array $session): ?array + { + $sessionId = trim((string) ($session['id'] ?? '')); + if ($sessionId === '') { + return null; + } + + $starts = $this->parseDate($session['scheduled_at'] ?? $content['starts_at'] ?? null); + if ($starts === null) { + return null; + } + + $duration = max(15, (int) ($session['duration_minutes'] ?? 60)); + $joinUrl = trim((string) ($session['join_url'] ?? '')); + $description = trim((string) ($content['description'] ?? '')); + + return [ + 'external_ref' => $this->sessionExternalRef($event->id, $sessionId), + 'title' => trim((string) ($session['title'] ?? $content['name'] ?? $event->label)), + 'body' => $description !== '' ? $description : null, + 'starts_at' => $starts->toIso8601String(), + 'ends_at' => $starts->copy()->addMinutes($duration)->toIso8601String(), + 'join_url' => $joinUrl !== '' ? $joinUrl : $event->publicUrl(), + ]; + } + + /** + * @param array $content + * @return array + */ + private function mainEventEntry(QrCode $event, array $content, string $titleSuffix = ''): array + { + $starts = $this->parseDate($content['starts_at'] ?? null) ?? now(); + $ends = $this->parseDate($content['ends_at'] ?? null) ?? $starts->copy()->addHours(2); + $name = trim((string) ($content['name'] ?? $event->label)); + $location = trim((string) ($content['location'] ?? '')); + $body = trim((string) ($content['description'] ?? '')); + if ($location !== '') { + $body = $body !== '' ? $body."\n\n".$location : $location; + } + + return [ + 'external_ref' => $this->eventExternalRef($event->id), + 'title' => $name.$titleSuffix, + 'body' => $body !== '' ? $body : null, + 'starts_at' => $starts->toIso8601String(), + 'ends_at' => $ends->toIso8601String(), + 'join_url' => $event->publicUrl(), + ]; + } + + private function eventExternalRef(int $eventId): string + { + return 'events:'.$eventId; + } + + private function sessionExternalRef(int $eventId, string $sessionId): string + { + return 'events:'.$eventId.':session:'.$sessionId; + } + + private function parseDate(mixed $value): ?CarbonInterface + { + if ($value === null || $value === '') { + return null; + } + + try { + return Carbon::parse($value); + } catch (\Throwable) { + return null; + } + } + + private function resolveMailbox(User $user): ?string + { + try { + $status = $this->identity->mailboxLinkStatus($user->public_id); + $linked = strtolower(trim((string) ($status['linked_mailbox'] ?? ''))); + if (filter_var($linked, FILTER_VALIDATE_EMAIL)) { + return $linked; + } + } catch (\Throwable) { + // Fall back to the account email. + } + + $email = strtolower(trim((string) $user->email)); + + return filter_var($email, FILTER_VALIDATE_EMAIL) ? $email : null; + } +} diff --git a/app/Services/Integrations/MailCalendarClient.php b/app/Services/Integrations/MailCalendarClient.php new file mode 100644 index 0000000..6f32675 --- /dev/null +++ b/app/Services/Integrations/MailCalendarClient.php @@ -0,0 +1,81 @@ + $payload + * @return array|null + */ + public function upsertEvent(array $payload): ?array + { + if (! $this->isConfigured()) { + return null; + } + + try { + $response = Http::withToken((string) config('mail_calendar.api_key')) + ->acceptJson() + ->asJson() + ->connectTimeout(10) + ->timeout(15) + ->post($this->baseUrl().'/calendar/events', $payload); + } catch (ConnectionException $e) { + Log::warning('Events mail calendar upsert failed', ['error' => $e->getMessage()]); + + return null; + } + + if (! $response->successful()) { + Log::warning('Events mail calendar upsert failed', [ + 'status' => $response->status(), + 'body' => $response->body(), + ]); + + return null; + } + + $data = $response->json('data'); + + return is_array($data) ? $data : null; + } + + public function deleteEvent(int $eventId, string $mailboxEmail, string $externalRef): bool + { + if (! $this->isConfigured()) { + return false; + } + + try { + $response = Http::withToken((string) config('mail_calendar.api_key')) + ->acceptJson() + ->connectTimeout(10) + ->timeout(15) + ->delete($this->baseUrl().'/calendar/events/'.$eventId, [ + 'mailbox_email' => strtolower(trim($mailboxEmail)), + 'external_ref' => $externalRef, + ]); + } catch (ConnectionException $e) { + Log::warning('Events mail calendar delete failed', ['error' => $e->getMessage()]); + + return false; + } + + return $response->successful(); + } + + protected function baseUrl(): string + { + return rtrim((string) config('mail_calendar.api_url'), '/'); + } +} diff --git a/app/Services/Qr/QrCodeManagerService.php b/app/Services/Qr/QrCodeManagerService.php index 95a7777..c957ffe 100644 --- a/app/Services/Qr/QrCodeManagerService.php +++ b/app/Services/Qr/QrCodeManagerService.php @@ -20,6 +20,7 @@ class QrCodeManagerService private QrImageGeneratorService $imageGenerator, private QrPayloadValidator $payloadValidator, private \App\Services\Meet\EventMeetSyncService $meetSync, + private \App\Services\Events\EventMailCalendarSyncService $mailCalendar, ) {} public function walletFor(User $user): QrWallet @@ -165,6 +166,7 @@ class QrCodeManagerService if ($type === QrCode::TYPE_EVENT) { $qrCode = $this->meetSync->sync($qrCode->fresh(), $user); + $qrCode = $this->mailCalendar->sync($qrCode->fresh(), $user); } return $qrCode->fresh(['document']); @@ -305,6 +307,7 @@ class QrCodeManagerService if ($qrCode->type === QrCode::TYPE_EVENT) { $qrCode = $this->meetSync->sync($qrCode->fresh(), $qrCode->user); + $qrCode = $this->mailCalendar->sync($qrCode->fresh(), $qrCode->user); } if ($qrCode->type === QrCode::TYPE_ITINERARY) { diff --git a/config/mail_calendar.php b/config/mail_calendar.php new file mode 100644 index 0000000..690d8fd --- /dev/null +++ b/config/mail_calendar.php @@ -0,0 +1,6 @@ + env('LADILL_MAIL_API_URL', 'https://mail.ladill.com/api/service/v1'), + 'api_key' => env('MAIL_API_KEY_EVENTS'), +]; diff --git a/storage/app/private/qr/1/codes/1/qr.png b/storage/app/private/qr/1/codes/1/qr.png new file mode 100644 index 0000000000000000000000000000000000000000..0a12863df9653819668ab4e47b698e469e6c8e3b GIT binary patch literal 1473 zcmeAS@N?(olHy`uVBq!ia0y~yVAKF%4kn;TO6qSvAjMhW5n0T@z;^_M8K-LVNi#68 z7JIrlhE&XXdpB@hw7Q7H#a*ZVET7ZY;I(B=&WGsN*1Ptd|0pK!e&G4{_xI}=>#yDa z%6-6J#fF1}pXpF@!v_TgDlzvjvw!gUr%(M6!QJ0k*8N*|wYcp6-*tE2|GaX4_wPUX ze{J7vdgSO(As`^f(!$R85XwCEyh8hO@uaE2#Y=8K3t4~uY21J83O8BaJC$3!@bt_|~XWPv$uCn{N_Va<;5g#%A_pGA+#Jhc)Ui~?L` zj$+1AP2TMMH|Hw-_Wu7;IpcDi{q#+@(j(3quf-N1-yW|GdG7D`Ufsr2f7LhMce`@$ zMVyVrrZmU?Uhe1Z!ONakT>V~fyK=_*KiK`9{;Xp8yx_O%cjxwn)c#){@>|Y-Blf6W zf2Moc^U}-bZ`S@>mv75|`ESLCX7qIDV0JvZ@LuxkuYV_Ke|yY$sJRIxO@3Hi*Z6<+ zF8kc_eI~a3yWY+@`RdQ5;P=L7yAX*8mYz<$J03mLuD$rW_Wkpxw2Sx0uHV036f>C{ z?2~^#b?4=ke@mBt`@J^g^O^8-|L6Rxw>gT<$a7cc%qzcZwtv@KnbR-N*R~d$K4cd} zOEQhG-yOH!dOqv>Pwnk@SBJedUwv-loO|ap``GO}P&@<7Ycb}>XV0_twXgpbbK&LB z2-AA&qdR9En1rsZX4PMh^&9x#-miUab3ulG-vo38J@1cKKmE7m?%({~zyFrmmtX(# z??CXXzxmtGuErL(Z(`0~e?Q$iwd(%ecg1C)^Q&*iduLm&*%L5uD$)Narykmbv8eo(2JCWzVGs(-*@_+zv*{( z^Y>qSJ1%3(Nx)pY{cd$x*1fGkZ`q?~?%V(F%A5~Zp5w@M<+o!}pH8j3`{lRK`TKD{ w=V48KcH7UIKTzL*C0)W&3u-|`is(Pa@1La`eyH1A0G7!Np00i_>zopr0GURCWdHyG literal 0 HcmV?d00001 diff --git a/storage/app/private/qr/1/codes/1/qr.svg b/storage/app/private/qr/1/codes/1/qr.svg new file mode 100644 index 0000000..dc8d803 --- /dev/null +++ b/storage/app/private/qr/1/codes/1/qr.svg @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + diff --git a/storage/app/private/qr/1/codes/2/qr.png b/storage/app/private/qr/1/codes/2/qr.png new file mode 100644 index 0000000000000000000000000000000000000000..15888bd720d331da87129bad5398a6a3c7d0e718 GIT binary patch literal 1475 zcmeAS@N?(olHy`uVBq!ia0y~yVAKF%4kn;TO6qSvAjMhW5n0T@z;^_M8K-LVNi#68 zmU_B4hE&XXdpEF8MqR|=V#$=h`IVcPlmtK4B>tL{zj{^9cTsu&1J8j#|H1iRyZapHHoATmMI{Eb=3!C$69Um}hM3Z@pDN@A{XRiT~4%V)}K)#u)aoqu2T_xpy^ zUC2&{_(Ed-UA#Q}nd|nSYd;@|4a1Db13zT)e?D?9-u&|S{jg>8i@i*@-}!oK z&vy|l-bnaUw`c0zpFyVUcb{EZzdiW;{jF91zU-zR2EMeDk{^P3OAN~IRj9ov6 z+0u6U?7DxNS7WYYap{hCF=ub@OTYf*=lrYHt9ND}-8t(6Hz!(BI8gdLYu%k~=hyDK z{%*>vc?ZIu&AE&%5xj{xJN2&RCGGEzecz{Bo0`v__kQi1U-j`jbLAIcNnR7)9=~0B z+iu;|m+x=ZzF&9!{qEaO>ui1~p{Mo_XLin7zrVbyuHw{dpwZ`J{jtT)vF8=r-|k=a z% zxz(xnwq{qY3w;lCZF~NHSMI$C+xnwef>~m}{QGTxqqOgr z_1}3{?f=*QURhlxR)zB)uMGhvvwypSKW}`scl+J)@27sw)W(v09L$bK&$LS~3NQVC z?@P`5E7i;AZ~px!{8=>?wFgRz_uQUytABgZZM%)4_3Jldi9N>RJ@!j(T{pK~zUrO* zp11qaelF{r5}E*XT7=I4 literal 0 HcmV?d00001 diff --git a/storage/app/private/qr/1/codes/2/qr.svg b/storage/app/private/qr/1/codes/2/qr.svg new file mode 100644 index 0000000..05e41bb --- /dev/null +++ b/storage/app/private/qr/1/codes/2/qr.svg @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + diff --git a/tests/Feature/EventMailCalendarSyncTest.php b/tests/Feature/EventMailCalendarSyncTest.php new file mode 100644 index 0000000..eacdd8c --- /dev/null +++ b/tests/Feature/EventMailCalendarSyncTest.php @@ -0,0 +1,126 @@ + 'https://mail.ladill.com/api/service/v1', + 'mail_calendar.api_key' => 'test-events-mail-key', + 'identity.api_url' => 'https://ladill.com/api', + 'identity.api_key' => 'test-identity-key', + ]); + + Http::fake([ + 'https://ladill.com/api/identity/mailbox-link*' => Http::response(['data' => []]), + 'https://mail.ladill.com/*' => Http::response(['data' => ['id' => 55]], 201), + ]); + } + + public function test_in_person_event_syncs_to_mail_calendar(): void + { + $owner = User::factory()->create(['public_id' => 'usr-cal-1', 'email' => 'host@example.com']); + $event = $this->makeEvent($owner, [ + 'format' => 'in_person', + 'name' => 'Town hall', + 'starts_at' => now()->addWeek()->toDateString(), + 'ends_at' => now()->addWeek()->addHours(3)->toDateString(), + ]); + + app(EventMailCalendarSyncService::class)->sync($event, $owner); + + Http::assertSent(function ($request) use ($event) { + return str_contains($request->url(), '/calendar/events') + && $request['mailbox_email'] === 'host@example.com' + && $request['external_ref'] === 'events:'.$event->id + && $request['title'] === 'Town hall'; + }); + + $this->assertSame( + ['events:'.$event->id => 55], + data_get($event->fresh()->payload, 'calendar_sync.items'), + ); + } + + public function test_virtual_session_syncs_one_entry_with_meet_join_url(): void + { + $owner = User::factory()->create(['public_id' => 'usr-cal-3', 'email' => 'host@example.com']); + $event = $this->makeEvent($owner, [ + 'format' => 'virtual', + 'name' => 'Product launch', + 'starts_at' => now()->addWeek()->toDateString(), + 'virtual_sessions' => [[ + 'id' => 'vs-main', + 'title' => 'Main stage', + 'scheduled_at' => now()->addWeek()->toIso8601String(), + 'duration_minutes' => 90, + 'join_url' => 'https://meet.ladill.com/join/demo', + ]], + ]); + + app(EventMailCalendarSyncService::class)->sync($event, $owner); + + Http::assertSent(function ($request) use ($event) { + return str_contains($request->url(), '/calendar/events') + && $request['external_ref'] === 'events:'.$event->id.':session:vs-main' + && $request['join_url'] === 'https://meet.ladill.com/join/demo'; + }); + + $this->assertSame(1, collect(Http::recorded())->filter( + fn (array $pair) => str_contains($pair[0]->url(), '/calendar/events'), + )->count()); + } + + public function test_uses_linked_mailbox_when_available(): void + { + Http::fake([ + 'https://mail.ladill.com/*' => Http::response(['data' => ['id' => 77]], 201), + ]); + + $this->mock(\App\Services\Identity\IdentityClient::class, function ($mock) { + $mock->shouldReceive('mailboxLinkStatus') + ->once() + ->andReturn(['linked_mailbox' => 'owner@customdomain.com']); + }); + + $owner = User::factory()->create(['public_id' => 'usr-cal-2', 'email' => 'sso@ladill.com']); + $event = $this->makeEvent($owner, [ + 'format' => 'in_person', + 'name' => 'Gala', + 'starts_at' => now()->addWeek()->toDateString(), + ]); + + app(EventMailCalendarSyncService::class)->sync($event, $owner); + + Http::assertSent(function ($request) { + return str_contains($request->url(), '/calendar/events') + && ($request['mailbox_email'] ?? '') === 'owner@customdomain.com'; + }); + } + + /** @param array $content */ + private function makeEvent(User $owner, array $content): QrCode + { + return QrCode::create([ + 'user_id' => $owner->id, + 'short_code' => 'evt-cal-'.uniqid(), + 'type' => QrCode::TYPE_EVENT, + 'label' => (string) ($content['name'] ?? 'Event'), + 'payload' => ['content' => $content, 'style' => []], + 'is_active' => true, + ]); + } +}