diff --git a/backend/app/DomainObjects/OrderDomainObject.php b/backend/app/DomainObjects/OrderDomainObject.php index cb2b4ace60..b3215d59f6 100644 --- a/backend/app/DomainObjects/OrderDomainObject.php +++ b/backend/app/DomainObjects/OrderDomainObject.php @@ -183,6 +183,16 @@ public function getSeatLabels(): array ->all(); } + public function getSingleEventOccurrence(): ?EventOccurrenceDomainObject + { + $occurrences = ($this->getOrderItems() ?? collect()) + ->map(fn (OrderItemDomainObject $item) => $item->getEventOccurrence()) + ->filter() + ->unique(fn (EventOccurrenceDomainObject $occurrence) => $occurrence->getId()); + + return $occurrences->count() === 1 ? $occurrences->first() : null; + } + public function isPaymentRequired(): bool { return (int) ceil($this->getTotalGross()) > 0; diff --git a/backend/app/Mail/Attendee/AttendeeTicketMail.php b/backend/app/Mail/Attendee/AttendeeTicketMail.php index d36edcfd75..9d543a0bcd 100644 --- a/backend/app/Mail/Attendee/AttendeeTicketMail.php +++ b/backend/app/Mail/Attendee/AttendeeTicketMail.php @@ -20,6 +20,7 @@ use Illuminate\Mail\Mailables\Attachment; use Illuminate\Mail\Mailables\Content; use Illuminate\Mail\Mailables\Envelope; +use Illuminate\Support\Collection; use Illuminate\Support\Str; use Spatie\IcalendarGenerator\Components\Calendar; use Spatie\IcalendarGenerator\Components\Event; @@ -39,6 +40,7 @@ public function __construct( private readonly OrganizerDomainObject $organizer, ?RenderedEmailTemplateDTO $renderedTemplate = null, private readonly ?EventOccurrenceDomainObject $occurrence = null, + private readonly ?Collection $attendees = null, ) { parent::__construct(); $this->renderedTemplate = $renderedTemplate; @@ -46,9 +48,10 @@ public function __construct( public function envelope(): Envelope { - $subject = $this->renderedTemplate?->subject ?? __('🎟️ Your Ticket for :event', [ - 'event' => Str::limit($this->event->getTitle(), 50), - ]); + $replacements = ['event' => Str::limit($this->event->getTitle(), 50)]; + $subject = $this->renderedTemplate?->subject ?? ($this->tickets()->count() > 1 + ? __('🎟️ Your Tickets for :event', $replacements) + : __('🎟️ Your Ticket for :event', $replacements)); return new Envelope( replyTo: $this->eventSettings->getSupportEmail(), @@ -69,30 +72,55 @@ public function content(): Content ); } - $occurrence = $this->occurrence ?? $this->attendee->getEventOccurrence(); - $eventLocation = $occurrence?->getEventLocation() ?? $this->event->getEventLocation(); - return new Content( markdown: 'emails.orders.attendee-ticket', with: [ 'event' => $this->event, - 'attendee' => $this->attendee, 'eventSettings' => $this->eventSettings, 'organizer' => $this->organizer, 'order' => $this->order, - 'occurrence' => $occurrence, - 'eventLocation' => $eventLocation, - 'effectiveVenueName' => $this->venueNameFor($eventLocation), - 'effectiveAddressString' => $this->addressStringFor($eventLocation), - 'ticketUrl' => sprintf( - Url::getFrontEndUrlFromConfig(Url::ATTENDEE_TICKET), - $this->event->getId(), - $this->attendee->getShortId(), - ), + 'tickets' => $this->tickets() + ->map(fn (AttendeeDomainObject $attendee) => $this->ticketViewData($attendee)) + ->all(), ] ); } + /** + * @return Collection + */ + private function tickets(): Collection + { + return ($this->attendees ?? null) ?? collect([$this->attendee]); + } + + private function occurrenceFor(AttendeeDomainObject $attendee): ?EventOccurrenceDomainObject + { + if ($this->tickets()->count() > 1) { + return $attendee->getEventOccurrence(); + } + + return $this->occurrence ?? $attendee->getEventOccurrence(); + } + + private function ticketViewData(AttendeeDomainObject $attendee): array + { + $occurrence = $this->occurrenceFor($attendee); + $eventLocation = $occurrence?->getEventLocation() ?? $this->event->getEventLocation(); + + return [ + 'attendee' => $attendee, + 'occurrence' => $occurrence, + 'venueName' => $this->venueNameFor($eventLocation), + 'addressString' => $this->addressStringFor($eventLocation), + 'ticketUrl' => sprintf( + Url::getFrontEndUrlFromConfig(Url::ATTENDEE_TICKET), + $this->event->getId(), + $attendee->getShortId(), + ), + ]; + } + private function venueNameFor(?EventLocationDomainObject $eventLocation): ?string { $venue = $this->venueLocation($eventLocation); @@ -140,7 +168,30 @@ private function venueLocation(?EventLocationDomainObject $eventLocation): ?Loca public function attachments(): array { - $occurrence = $this->occurrence ?? null; + $calendarEvents = $this->tickets() + ->groupBy(fn (AttendeeDomainObject $attendee) => $this->occurrenceFor($attendee)?->getId() ?? 0) + ->map(fn (Collection $attendees) => $this->calendarEventFor($attendees->first())) + ->filter() + ->values() + ->all(); + + if ($calendarEvents === []) { + return []; + } + + $calendar = Calendar::create() + ->event($calendarEvents) + ->get(); + + return [ + Attachment::fromData(static fn () => $calendar, 'event.ics') + ->withMime('text/calendar'), + ]; + } + + private function calendarEventFor(AttendeeDomainObject $attendee): ?Event + { + $occurrence = $this->occurrenceFor($attendee); $startDateRaw = $occurrence?->getStartDate() ?? $this->event->getStartDate(); $endDateRaw = $occurrence?->getEndDate() ?? $this->event->getEndDate(); @@ -149,7 +200,7 @@ public function attachments(): array $endDateTime = $endDateRaw ? Carbon::parse($endDateRaw, $this->event->getTimezone()) : null; if ($startDateTime === null) { - return []; + return null; } $eventTitle = $this->event->getTitle(); @@ -159,7 +210,7 @@ public function attachments(): array $event = Event::create() ->name($eventTitle) - ->uniqueIdentifier('event-'.$this->attendee->getId()) + ->uniqueIdentifier('event-'.$attendee->getId()) ->startsAt($startDateTime) ->url($this->event->getEventUrl()) ->organizer($this->organizer->getEmail(), $this->organizer->getName()); @@ -168,7 +219,6 @@ public function attachments(): array $event->description(StringHelper::previewFromHtml($this->event->getDescription())); } - $occurrence = $this->occurrence ?? $this->attendee->getEventOccurrence(); $eventLocation = $occurrence?->getEventLocation() ?? $this->event->getEventLocation(); $address = $this->addressStringFor($eventLocation); if ($address !== null) { @@ -182,13 +232,6 @@ public function attachments(): array $event->endsAt($endDateTime); } - $calendar = Calendar::create() - ->event($event) - ->get(); - - return [ - Attachment::fromData(static fn () => $calendar, 'event.ics') - ->withMime('text/calendar'), - ]; + return $event; } } diff --git a/backend/app/Mail/Order/OrderSummary.php b/backend/app/Mail/Order/OrderSummary.php index 83b4fcaa2e..847ddd9fa0 100644 --- a/backend/app/Mail/Order/OrderSummary.php +++ b/backend/app/Mail/Order/OrderSummary.php @@ -3,7 +3,6 @@ namespace HiEvents\Mail\Order; use HiEvents\DomainObjects\EventDomainObject; -use HiEvents\DomainObjects\EventOccurrenceDomainObject; use HiEvents\DomainObjects\EventSettingDomainObject; use HiEvents\DomainObjects\InvoiceDomainObject; use HiEvents\DomainObjects\OrderDomainObject; @@ -29,7 +28,6 @@ public function __construct( private readonly OrganizerDomainObject $organizer, private readonly EventSettingDomainObject $eventSettings, private readonly ?InvoiceDomainObject $invoice, - private readonly ?EventOccurrenceDomainObject $occurrence = null, ?RenderedEmailTemplateDTO $renderedTemplate = null, ) { $this->renderedTemplate = $renderedTemplate; @@ -60,7 +58,6 @@ public function content(): Content ); } - // Fallback to original template return new Content( markdown: 'emails.orders.summary', with: [ @@ -68,7 +65,7 @@ public function content(): Content 'event' => $this->event, 'order' => $this->order, 'organizer' => $this->organizer, - 'occurrence' => $this->occurrence ?? null, + 'occurrence' => $this->order->getSingleEventOccurrence(), 'orderUrl' => sprintf( Url::getFrontEndUrlFromConfig(Url::ORDER_SUMMARY), $this->event->getId(), diff --git a/backend/app/Services/Domain/Attendee/SendAttendeeTicketService.php b/backend/app/Services/Domain/Attendee/SendAttendeeTicketService.php index c92517171f..2dca49e0b2 100644 --- a/backend/app/Services/Domain/Attendee/SendAttendeeTicketService.php +++ b/backend/app/Services/Domain/Attendee/SendAttendeeTicketService.php @@ -9,6 +9,7 @@ use HiEvents\DomainObjects\OrganizerDomainObject; use HiEvents\Services\Domain\Email\MailBuilderService; use Illuminate\Contracts\Mail\Mailer; +use Illuminate\Support\Collection; class SendAttendeeTicketService { @@ -42,4 +43,35 @@ public function send( ->locale($attendee->getLocale()) ->send($mail); } + + /** + * @param Collection $attendees + */ + public function sendTickets( + OrderDomainObject $order, + Collection $attendees, + EventDomainObject $event, + EventSettingDomainObject $eventSettings, + OrganizerDomainObject $organizer, + ): void { + $recipient = $attendees->first(); + if ($recipient?->getEmail() === null) { + return; + } + + $mails = $this->mailBuilderService->buildAttendeeTicketMails( + $attendees, + $order, + $event, + $eventSettings, + $organizer, + ); + + foreach ($mails as $mail) { + $this->mailer + ->to($recipient->getEmail()) + ->locale($recipient->getLocale()) + ->send($mail); + } + } } diff --git a/backend/app/Services/Domain/Email/EmailTokenContextBuilder.php b/backend/app/Services/Domain/Email/EmailTokenContextBuilder.php index 458d559cb6..4ac52be2fb 100644 --- a/backend/app/Services/Domain/Email/EmailTokenContextBuilder.php +++ b/backend/app/Services/Domain/Email/EmailTokenContextBuilder.php @@ -38,6 +38,8 @@ public function buildOrderConfirmationContext( EventSettingDomainObject $eventSettings, ?EventOccurrenceDomainObject $occurrence = null, ): array { + $occurrence ??= $order->getSingleEventOccurrence(); + $startDateRaw = $occurrence?->getStartDate() ?? $event->getStartDate(); $endDateRaw = $occurrence?->getEndDate() ?? $event->getEndDate(); diff --git a/backend/app/Services/Domain/Email/MailBuilderService.php b/backend/app/Services/Domain/Email/MailBuilderService.php index 4f0e4d55ce..4366e12eb3 100644 --- a/backend/app/Services/Domain/Email/MailBuilderService.php +++ b/backend/app/Services/Domain/Email/MailBuilderService.php @@ -4,6 +4,7 @@ use Carbon\Carbon; use HiEvents\DomainObjects\AttendeeDomainObject; +use HiEvents\DomainObjects\EmailTemplateDomainObject; use HiEvents\DomainObjects\Enums\EmailTemplateType; use HiEvents\DomainObjects\EventDomainObject; use HiEvents\DomainObjects\EventOccurrenceDomainObject; @@ -17,6 +18,7 @@ use HiEvents\Mail\Order\OrderSummary; use HiEvents\Services\Domain\Email\DTO\RenderedEmailTemplateDTO; use HiEvents\Services\Domain\Order\OfflinePaymentInstructionsRenderService; +use Illuminate\Support\Collection; class MailBuilderService { @@ -34,7 +36,8 @@ public function buildAttendeeTicketMail( OrganizerDomainObject $organizer, ?EventOccurrenceDomainObject $occurrence = null, ): AttendeeTicketMail { - $renderedTemplate = $this->renderAttendeeTicketTemplate( + return $this->makeAttendeeTicketMail( + $this->getAttendeeTicketTemplate($event, $organizer), $attendee, $order, $event, @@ -42,16 +45,53 @@ public function buildAttendeeTicketMail( $organizer, $occurrence, ); + } - return new AttendeeTicketMail( - order: $order, - attendee: $attendee, - event: $event, - eventSettings: $eventSettings, - organizer: $organizer, - renderedTemplate: $renderedTemplate, - occurrence: $occurrence, - ); + /** + * @param Collection $attendees + * @return AttendeeTicketMail[] + */ + public function buildAttendeeTicketMails( + Collection $attendees, + OrderDomainObject $order, + EventDomainObject $event, + EventSettingDomainObject $eventSettings, + OrganizerDomainObject $organizer, + ): array { + $attendees = $attendees + ->sortBy([ + fn (AttendeeDomainObject $a, AttendeeDomainObject $b) => $a->getEventOccurrence()?->getStartDate() <=> $b->getEventOccurrence()?->getStartDate(), + fn (AttendeeDomainObject $a, AttendeeDomainObject $b) => $a->getId() <=> $b->getId(), + ]) + ->values(); + + $template = $this->getAttendeeTicketTemplate($event, $organizer); + + if ($template !== null || $attendees->count() === 1) { + return $attendees + ->map(fn (AttendeeDomainObject $attendee) => $this->makeAttendeeTicketMail( + $template, + $attendee, + $order, + $event, + $eventSettings, + $organizer, + $attendee->getEventOccurrence(), + )) + ->all(); + } + + return [ + new AttendeeTicketMail( + order: $order, + attendee: $attendees->first(), + event: $event, + eventSettings: $eventSettings, + organizer: $organizer, + occurrence: $attendees->first()->getEventOccurrence(), + attendees: $attendees, + ), + ]; } public function buildOrderSummaryMail( @@ -60,14 +100,12 @@ public function buildOrderSummaryMail( EventSettingDomainObject $eventSettings, OrganizerDomainObject $organizer, ?InvoiceDomainObject $invoice = null, - ?EventOccurrenceDomainObject $occurrence = null, ): OrderSummary { $renderedTemplate = $this->renderOrderSummaryTemplate( $order, $event, $eventSettings, $organizer, - $occurrence, ); if (! $renderedTemplate) { @@ -80,30 +118,59 @@ public function buildOrderSummaryMail( organizer: $organizer, eventSettings: $eventSettings, invoice: $invoice, - occurrence: $occurrence, renderedTemplate: $renderedTemplate, ); } - private function renderAttendeeTicketTemplate( + private function makeAttendeeTicketMail( + ?EmailTemplateDomainObject $template, AttendeeDomainObject $attendee, OrderDomainObject $order, EventDomainObject $event, EventSettingDomainObject $eventSettings, OrganizerDomainObject $organizer, - ?EventOccurrenceDomainObject $occurrence = null, - ): ?RenderedEmailTemplateDTO { - $template = $this->emailTemplateService->getTemplateByType( + ?EventOccurrenceDomainObject $occurrence, + ): AttendeeTicketMail { + return new AttendeeTicketMail( + order: $order, + attendee: $attendee, + event: $event, + eventSettings: $eventSettings, + organizer: $organizer, + renderedTemplate: $template === null ? null : $this->renderAttendeeTicketTemplate( + $template, + $attendee, + $order, + $event, + $eventSettings, + $organizer, + $occurrence, + ), + occurrence: $occurrence, + ); + } + + private function getAttendeeTicketTemplate( + EventDomainObject $event, + OrganizerDomainObject $organizer, + ): ?EmailTemplateDomainObject { + return $this->emailTemplateService->getTemplateByType( type: EmailTemplateType::ATTENDEE_TICKET, accountId: $event->getAccountId(), eventId: $event->getId(), organizerId: $organizer->getId() ); + } - if (! $template) { - return null; - } - + private function renderAttendeeTicketTemplate( + EmailTemplateDomainObject $template, + AttendeeDomainObject $attendee, + OrderDomainObject $order, + EventDomainObject $event, + EventSettingDomainObject $eventSettings, + OrganizerDomainObject $organizer, + ?EventOccurrenceDomainObject $occurrence = null, + ): RenderedEmailTemplateDTO { $context = $this->tokenContextBuilder->buildAttendeeTicketContext( $attendee, $order, @@ -121,7 +188,6 @@ private function renderOrderSummaryTemplate( EventDomainObject $event, EventSettingDomainObject $eventSettings, OrganizerDomainObject $organizer, - ?EventOccurrenceDomainObject $occurrence = null, ): ?RenderedEmailTemplateDTO { $template = $this->emailTemplateService->getTemplateByType( type: EmailTemplateType::ORDER_CONFIRMATION, @@ -139,7 +205,6 @@ private function renderOrderSummaryTemplate( $event, $organizer, $eventSettings, - $occurrence, ); return $this->emailTemplateService->renderTemplate($template, $context); diff --git a/backend/app/Services/Domain/Mail/SendOrderDetailsService.php b/backend/app/Services/Domain/Mail/SendOrderDetailsService.php index d2e461ac29..a5f50cb67e 100644 --- a/backend/app/Services/Domain/Mail/SendOrderDetailsService.php +++ b/backend/app/Services/Domain/Mail/SendOrderDetailsService.php @@ -13,6 +13,7 @@ use HiEvents\DomainObjects\OrderItemDomainObject; use HiEvents\DomainObjects\OrganizerDomainObject; use HiEvents\DomainObjects\ProductDomainObject; +use HiEvents\DomainObjects\Status\AttendeeStatus; use HiEvents\Mail\Order\OrderFailed; use HiEvents\Mail\Organizer\OrderSummaryForOrganizer; use HiEvents\Repository\Eloquent\Value\Relationship; @@ -21,6 +22,7 @@ use HiEvents\Services\Domain\Attendee\SendAttendeeTicketService; use HiEvents\Services\Domain\Email\MailBuilderService; use Illuminate\Mail\Mailer; +use Illuminate\Support\Collection; class SendOrderDetailsService { @@ -107,7 +109,6 @@ public function sendCustomerOrderSummary( OrganizerDomainObject $organizer, EventSettingDomainObject $eventSettings, ?InvoiceDomainObject $invoice = null, - ?EventOccurrenceDomainObject $occurrence = null, ): void { if ($order->getEmail() === null) { return; @@ -119,7 +120,6 @@ public function sendCustomerOrderSummary( $eventSettings, $organizer, $invoice, - $occurrence ?? $this->resolvePrimaryOccurrence($order), ); $this->mailer @@ -128,39 +128,19 @@ public function sendCustomerOrderSummary( ->send($mail); } - private function resolvePrimaryOccurrence(OrderDomainObject $order): ?EventOccurrenceDomainObject - { - $items = $order->getOrderItems(); - if ($items === null || $items->isEmpty()) { - return null; - } - - $distinct = $items - ->map(fn (OrderItemDomainObject $item) => $item->getEventOccurrence()) - ->filter() - ->unique(fn (EventOccurrenceDomainObject $occ) => $occ->getId()); - - return $distinct->count() === 1 ? $distinct->first() : null; - } - private function sendAttendeeTicketEmails(OrderDomainObject $order, EventDomainObject $event): void { - $sentEmails = []; - foreach ($order->getAttendees() as $attendee) { - if ($attendee->getEmail() === null || in_array($attendee->getEmail(), $sentEmails, true)) { - continue; - } - - $this->sendAttendeeTicketService->send( + ($order->getAttendees() ?? collect()) + ->filter(fn (AttendeeDomainObject $attendee) => $attendee->getEmail() !== null + && $attendee->getStatus() !== AttendeeStatus::CANCELLED->name) + ->groupBy(fn (AttendeeDomainObject $attendee) => $attendee->getEmail()) + ->each(fn (Collection $attendees) => $this->sendAttendeeTicketService->sendTickets( order: $order, - attendee: $attendee, + attendees: $attendees->values(), event: $event, eventSettings: $event->getEventSettings(), organizer: $event->getOrganizer(), - ); - - $sentEmails[] = $attendee->getEmail(); - } + )); } private function sendOrderSummaryEmails(OrderDomainObject $order, EventDomainObject $event): void diff --git a/backend/lang/de.json b/backend/lang/de.json index f46171601b..4edaab1131 100644 --- a/backend/lang/de.json +++ b/backend/lang/de.json @@ -955,6 +955,7 @@ "Partially refunded": "Teilweise erstattet", "Refund pending": "Erstattung ausstehend", "Refund failed": "Erstattung fehlgeschlagen", + "🎟️ Your Tickets for :event": "🎟️ Ihre Tickets für :event", "Event deleted": "Veranstaltung gelöscht", "Event deleted and account deletion queued": "Veranstaltung gelöscht und Kontolöschung eingeplant", "No flagged event awaiting review was found": "Keine markierte Veranstaltung zur Prüfung gefunden", diff --git a/backend/lang/el.json b/backend/lang/el.json index 3a1780fd22..889a292183 100644 --- a/backend/lang/el.json +++ b/backend/lang/el.json @@ -955,6 +955,7 @@ "Partially refunded": "Μερική επιστροφή χρημάτων", "Refund pending": "Εκκρεμεί επιστροφή χρημάτων", "Refund failed": "Η επιστροφή χρημάτων απέτυχε", + "🎟️ Your Tickets for :event": "🎟️ Τα Εισιτήριά σας για :event", "Event deleted": "Η εκδήλωση διαγράφηκε", "Event deleted and account deletion queued": "Η εκδήλωση διαγράφηκε και η διαγραφή του λογαριασμού μπήκε στην ουρά", "No flagged event awaiting review was found": "Δεν βρέθηκε επισημασμένη εκδήλωση προς έλεγχο", diff --git a/backend/lang/es.json b/backend/lang/es.json index eb0696a63d..eb453b0ded 100644 --- a/backend/lang/es.json +++ b/backend/lang/es.json @@ -995,6 +995,7 @@ "Partially refunded": "Reembolsado parcialmente", "Refund pending": "Reembolso pendiente", "Refund failed": "Reembolso fallido", + "🎟️ Your Tickets for :event": "🎟️ Sus entradas para :event", "Event deleted": "Evento eliminado", "Event deleted and account deletion queued": "Evento eliminado y eliminación de la cuenta en cola", "No flagged event awaiting review was found": "No se encontró ningún evento marcado pendiente de revisión", diff --git a/backend/lang/fi.json b/backend/lang/fi.json index 49b87c34f8..eb4c226e8d 100644 --- a/backend/lang/fi.json +++ b/backend/lang/fi.json @@ -1143,6 +1143,7 @@ "Partially refunded": "Osittain hyvitetty", "Refund pending": "Hyvitys odottaa", "Refund failed": "Hyvitys epäonnistui", + "🎟️ Your Tickets for :event": "🎟️ Lippusi tapahtumaan :event", "Event deleted": "Tapahtuma poistettu", "Event deleted and account deletion queued": "Tapahtuma poistettu ja tilin poisto jonossa", "No flagged event awaiting review was found": "Tarkistusta odottavaa merkittyä tapahtumaa ei löytynyt", diff --git a/backend/lang/fr.json b/backend/lang/fr.json index 131ead1f5b..fc524d5c66 100644 --- a/backend/lang/fr.json +++ b/backend/lang/fr.json @@ -955,6 +955,7 @@ "Partially refunded": "Partiellement remboursé", "Refund pending": "Remboursement en attente", "Refund failed": "Échec du remboursement", + "🎟️ Your Tickets for :event": "🎟️ Vos billets pour :event", "Event deleted": "Événement supprimé", "Event deleted and account deletion queued": "Événement supprimé et suppression du compte en file d'attente", "No flagged event awaiting review was found": "Aucun événement signalé en attente d'examen n'a été trouvé", diff --git a/backend/lang/hu.json b/backend/lang/hu.json index 217620385f..40fb569d24 100644 --- a/backend/lang/hu.json +++ b/backend/lang/hu.json @@ -960,6 +960,7 @@ "Partially refunded": "Részben visszatérítve", "Refund pending": "Visszatérítés folyamatban", "Refund failed": "Sikertelen visszatérítés", + "🎟️ Your Tickets for :event": "🎟️ Az Ön jegyei a következőre: :event", "Event deleted": "Esemény törölve", "Event deleted and account deletion queued": "Esemény törölve, a fiók törlése sorba állítva", "No flagged event awaiting review was found": "Nem található ellenőrzésre váró megjelölt esemény", diff --git a/backend/lang/it.json b/backend/lang/it.json index 976c9befa7..2c4c285597 100644 --- a/backend/lang/it.json +++ b/backend/lang/it.json @@ -956,6 +956,7 @@ "Partially refunded": "Rimborsato parzialmente", "Refund pending": "Rimborso in sospeso", "Refund failed": "Rimborso non riuscito", + "🎟️ Your Tickets for :event": "🎟️ I tuoi biglietti per :event", "Event deleted": "Evento eliminato", "Event deleted and account deletion queued": "Evento eliminato ed eliminazione dell'account in coda", "No flagged event awaiting review was found": "Nessun evento segnalato in attesa di revisione trovato", diff --git a/backend/lang/ko.json b/backend/lang/ko.json index bdca661326..54c7f26839 100644 --- a/backend/lang/ko.json +++ b/backend/lang/ko.json @@ -1144,6 +1144,7 @@ "Partially refunded": "부분 환불됨", "Refund pending": "환불 대기 중", "Refund failed": "환불 실패", + "🎟️ Your Tickets for :event": "🎟️ :event 티켓", "Event deleted": "이벤트가 삭제되었습니다", "Event deleted and account deletion queued": "이벤트가 삭제되었고 계정 삭제가 대기열에 추가되었습니다", "No flagged event awaiting review was found": "검토 대기 중인 신고된 이벤트를 찾을 수 없습니다", diff --git a/backend/lang/nl.json b/backend/lang/nl.json index 1d4f15bf35..b973f881c7 100644 --- a/backend/lang/nl.json +++ b/backend/lang/nl.json @@ -955,6 +955,7 @@ "Partially refunded": "Gedeeltelijk terugbetaald", "Refund pending": "Terugbetaling in behandeling", "Refund failed": "Terugbetaling mislukt", + "🎟️ Your Tickets for :event": "🎟️ Uw tickets voor :event", "Event deleted": "Evenement verwijderd", "Event deleted and account deletion queued": "Evenement verwijderd en accountverwijdering ingepland", "No flagged event awaiting review was found": "Geen gemarkeerd evenement gevonden dat op beoordeling wacht", diff --git a/backend/lang/pl.json b/backend/lang/pl.json index 349907a480..1cc15458a0 100644 --- a/backend/lang/pl.json +++ b/backend/lang/pl.json @@ -1143,6 +1143,7 @@ "Partially refunded": "Częściowo zwrócono", "Refund pending": "Zwrot w toku", "Refund failed": "Zwrot nieudany", + "🎟️ Your Tickets for :event": "🎟️ Twoje bilety na :event", "Event deleted": "Wydarzenie usunięte", "Event deleted and account deletion queued": "Wydarzenie usunięte, usunięcie konta w kolejce", "No flagged event awaiting review was found": "Nie znaleziono oznaczonego wydarzenia oczekującego na weryfikację", diff --git a/backend/lang/pt-br.json b/backend/lang/pt-br.json index d2ec6cd906..ec8e3213f8 100644 --- a/backend/lang/pt-br.json +++ b/backend/lang/pt-br.json @@ -955,6 +955,7 @@ "Partially refunded": "Parcialmente reembolsado", "Refund pending": "Reembolso pendente", "Refund failed": "Falha no reembolso", + "🎟️ Your Tickets for :event": "🎟️ Seus ingressos para :event", "Event deleted": "Evento excluído", "Event deleted and account deletion queued": "Evento excluído e exclusão da conta na fila", "No flagged event awaiting review was found": "Nenhum evento sinalizado aguardando revisão foi encontrado", diff --git a/backend/lang/pt.json b/backend/lang/pt.json index 19f6746357..0e6c14c89a 100644 --- a/backend/lang/pt.json +++ b/backend/lang/pt.json @@ -955,6 +955,7 @@ "Partially refunded": "Parcialmente reembolsado", "Refund pending": "Reembolso pendente", "Refund failed": "Falha no reembolso", + "🎟️ Your Tickets for :event": "🎟️ Os seus bilhetes para :event", "Event deleted": "Evento eliminado", "Event deleted and account deletion queued": "Evento eliminado e eliminação da conta em fila", "No flagged event awaiting review was found": "Não foi encontrado nenhum evento sinalizado a aguardar revisão", diff --git a/backend/lang/ru.json b/backend/lang/ru.json index 59a123d967..99f031270d 100644 --- a/backend/lang/ru.json +++ b/backend/lang/ru.json @@ -943,6 +943,7 @@ "Partially refunded": "Частично возвращено", "Refund pending": "Возврат в обработке", "Refund failed": "Ошибка возврата", + "🎟️ Your Tickets for :event": "🎟️ Ваши билеты на :event", "Event deleted": "Мероприятие удалено", "Event deleted and account deletion queued": "Мероприятие удалено, удаление аккаунта поставлено в очередь", "No flagged event awaiting review was found": "Отмеченное мероприятие, ожидающее проверки, не найдено", diff --git a/backend/lang/se.json b/backend/lang/se.json index 2dcdd949a7..0aedd9b42a 100644 --- a/backend/lang/se.json +++ b/backend/lang/se.json @@ -955,6 +955,7 @@ "Partially refunded": "Delvis återbetald", "Refund pending": "Återbetalning väntar", "Refund failed": "Återbetalning misslyckades", + "🎟️ Your Tickets for :event": "🎟️ Dina biljetter till :event", "Event deleted": "Evenemang raderat", "Event deleted and account deletion queued": "Evenemang raderat och kontoradering köad", "No flagged event awaiting review was found": "Inget flaggat evenemang som väntar på granskning hittades", diff --git a/backend/lang/sk.json b/backend/lang/sk.json index c4b0d93ccd..8c7c254a02 100644 --- a/backend/lang/sk.json +++ b/backend/lang/sk.json @@ -955,6 +955,7 @@ "Partially refunded": "Čiastočne refundované", "Refund pending": "Refundácia čaká", "Refund failed": "Refundácia zlyhala", + "🎟️ Your Tickets for :event": "🎟️ Vaše lístky na :event", "Event deleted": "Udalosť odstránená", "Event deleted and account deletion queued": "Udalosť odstránená a odstránenie účtu zaradené do fronty", "No flagged event awaiting review was found": "Nenašla sa žiadna označená udalosť čakajúca na kontrolu", diff --git a/backend/lang/tr.json b/backend/lang/tr.json index 9c39357a5d..71b0722390 100644 --- a/backend/lang/tr.json +++ b/backend/lang/tr.json @@ -970,6 +970,7 @@ "Partially refunded": "Kısmen iade edildi", "Refund pending": "İade bekleniyor", "Refund failed": "İade başarısız", + "🎟️ Your Tickets for :event": "🎟️ :event için Biletleriniz", "Event deleted": "Etkinlik silindi", "Event deleted and account deletion queued": "Etkinlik silindi ve hesap silme işlemi sıraya alındı", "No flagged event awaiting review was found": "İnceleme bekleyen işaretli etkinlik bulunamadı", diff --git a/backend/lang/vi.json b/backend/lang/vi.json index 35b1807e9a..feceb60244 100644 --- a/backend/lang/vi.json +++ b/backend/lang/vi.json @@ -955,6 +955,7 @@ "Partially refunded": "Hoàn tiền một phần", "Refund pending": "Đang chờ hoàn tiền", "Refund failed": "Hoàn tiền thất bại", + "🎟️ Your Tickets for :event": "🎟️ Vé của bạn cho :event", "Event deleted": "Đã xóa sự kiện", "Event deleted and account deletion queued": "Đã xóa sự kiện và đưa việc xóa tài khoản vào hàng đợi", "No flagged event awaiting review was found": "Không tìm thấy sự kiện bị gắn cờ đang chờ xem xét", diff --git a/backend/lang/zh-cn.json b/backend/lang/zh-cn.json index 9183ee1e4b..bffdd83e8a 100644 --- a/backend/lang/zh-cn.json +++ b/backend/lang/zh-cn.json @@ -955,6 +955,7 @@ "Partially refunded": "部分退款", "Refund pending": "退款处理中", "Refund failed": "退款失败", + "🎟️ Your Tickets for :event": "🎟️ 您参加 :event 的门票", "Event deleted": "活动已删除", "Event deleted and account deletion queued": "活动已删除,账户删除已加入队列", "No flagged event awaiting review was found": "未找到待审核的已标记活动", diff --git a/backend/lang/zh-hk.json b/backend/lang/zh-hk.json index 1e649837ad..4cfb314317 100644 --- a/backend/lang/zh-hk.json +++ b/backend/lang/zh-hk.json @@ -955,6 +955,7 @@ "Partially refunded": "部分退款", "Refund pending": "退款處理中", "Refund failed": "退款失敗", + "🎟️ Your Tickets for :event": "🎟️ 您參加 :event 的門票", "Event deleted": "活動已刪除", "Event deleted and account deletion queued": "活動已刪除,帳戶刪除已加入佇列", "No flagged event awaiting review was found": "找不到待審核的已標記活動", diff --git a/backend/resources/views/emails/orders/attendee-ticket.blade.php b/backend/resources/views/emails/orders/attendee-ticket.blade.php index 8e918a62cd..7785450052 100644 --- a/backend/resources/views/emails/orders/attendee-ticket.blade.php +++ b/backend/resources/views/emails/orders/attendee-ticket.blade.php @@ -2,16 +2,12 @@ @php /** @var \HiEvents\DomainObjects\EventDomainObject $event */ @endphp @php /** @var \HiEvents\DomainObjects\EventSettingDomainObject $eventSettings */ @endphp @php /** @var \HiEvents\DomainObjects\OrganizerDomainObject $organizer */ @endphp -@php /** @var \HiEvents\DomainObjects\AttendeeDomainObject $attendee */ @endphp @php /** @var \HiEvents\DomainObjects\OrderDomainObject $order */ @endphp -@php /** @var \HiEvents\DomainObjects\EventOccurrenceDomainObject|null $occurrence */ @endphp -@php /** @var string $ticketUrl */ @endphp +@php /** @var array $tickets */ @endphp @php /** @see \HiEvents\Mail\Attendee\AttendeeTicketMail */ @endphp @php $tz = $event->getTimezone(); - $displayStart = $occurrence?->getStartDate() ?? $event->getStartDate(); - $displayEnd = $occurrence?->getEndDate() ?? $event->getEndDate(); $formatDateTime = static fn(?string $utc) => $utc ? (new Carbon(DateHelper::convertFromUTC($utc, $tz)))->format('D, M j, Y · g:i A') @@ -19,18 +15,6 @@ $formatTime = static fn(?string $utc) => $utc ? (new Carbon(DateHelper::convertFromUTC($utc, $tz)))->format('g:i A') : null; - - $startFormatted = $formatDateTime($displayStart); - $endFormatted = null; - if ($displayStart && $displayEnd) { - // Same day → show just the end time; cross-day → show the full end timestamp. - $sameDay = substr($displayStart, 0, 10) === substr($displayEnd, 0, 10); - $endFormatted = $sameDay ? $formatTime($displayEnd) : $formatDateTime($displayEnd); - } - - $venueName = $effectiveVenueName ?? null; - $addressString = $effectiveAddressString ?? null; - $productTitle = $attendee->getProduct()?->getTitle(); @endphp @@ -46,6 +30,24 @@ {{ __('Please find your ticket details below.') }} +@foreach($tickets as $ticket) +@php + $attendee = $ticket['attendee']; + $occurrence = $ticket['occurrence']; + $venueName = $ticket['venueName']; + $addressString = $ticket['addressString']; + $displayStart = $occurrence?->getStartDate() ?? $event->getStartDate(); + $displayEnd = $occurrence?->getEndDate() ?? $event->getEndDate(); + + $startFormatted = $formatDateTime($displayStart); + $endFormatted = null; + if ($displayStart && $displayEnd) { + $sameDay = substr($displayStart, 0, 10) === substr($displayEnd, 0, 10); + $endFormatted = $sameDay ? $formatTime($displayEnd) : $formatDateTime($displayEnd); + } + + $productTitle = $attendee->getProduct()?->getTitle(); +@endphp @if($startFormatted || $venueName || $addressString || $productTitle)
@if($startFormatted) @@ -67,10 +69,11 @@
@endif - + {{ __('View Ticket') }} +@endforeach {{ __('If you have any questions or need assistance, please reply to this email or contact the event organizer') }} {{ __('at') }} {{$eventSettings->getSupportEmail()}}. diff --git a/backend/tests/Unit/DomainObjects/OrderDomainObjectSingleEventOccurrenceTest.php b/backend/tests/Unit/DomainObjects/OrderDomainObjectSingleEventOccurrenceTest.php new file mode 100644 index 0000000000..beaf69f2c4 --- /dev/null +++ b/backend/tests/Unit/DomainObjects/OrderDomainObjectSingleEventOccurrenceTest.php @@ -0,0 +1,38 @@ +setId(1)->setStartDate('2026-11-05 18:00:00'); + + $order = (new OrderDomainObject)->setOrderItems(collect([ + (new OrderItemDomainObject)->setEventOccurrence($occurrence), + (new OrderItemDomainObject)->setEventOccurrence($occurrence), + ])); + + $this->assertSame($occurrence, $order->getSingleEventOccurrence()); + } + + public function test_is_null_when_items_span_several_occurrences(): void + { + $order = (new OrderDomainObject)->setOrderItems(collect([ + (new OrderItemDomainObject)->setEventOccurrence((new EventOccurrenceDomainObject)->setId(1)), + (new OrderItemDomainObject)->setEventOccurrence((new EventOccurrenceDomainObject)->setId(2)), + ])); + + $this->assertNull($order->getSingleEventOccurrence()); + } + + public function test_is_null_when_order_items_are_not_loaded(): void + { + $this->assertNull((new OrderDomainObject)->getSingleEventOccurrence()); + } +} diff --git a/backend/tests/Unit/Mail/Attendee/AttendeeTicketMailTest.php b/backend/tests/Unit/Mail/Attendee/AttendeeTicketMailTest.php index 631a2fddb5..5ee8182129 100644 --- a/backend/tests/Unit/Mail/Attendee/AttendeeTicketMailTest.php +++ b/backend/tests/Unit/Mail/Attendee/AttendeeTicketMailTest.php @@ -6,6 +6,8 @@ use HiEvents\DomainObjects\AttendeeDomainObject; use HiEvents\DomainObjects\EventDomainObject; use HiEvents\DomainObjects\EventOccurrenceDomainObject; +use HiEvents\DomainObjects\EventSettingDomainObject; +use HiEvents\DomainObjects\OrderDomainObject; use HiEvents\DomainObjects\OrganizerDomainObject; use HiEvents\Mail\Attendee\AttendeeTicketMail; use ReflectionClass; @@ -42,4 +44,36 @@ public function test_attachments_render_for_a_mail_queued_by_a_previous_release( $this->assertCount(1, $attachments); } + + public function test_content_renders_one_ticket_for_a_mail_queued_before_grouping(): void + { + $event = (new EventDomainObject) + ->setId(1) + ->setTitle('Test Event') + ->setTimezone('UTC') + ->setEventOccurrences(collect()); + + $attendee = (new AttendeeDomainObject)->setId(5)->setShortId('a_old'); + $occurrence = (new EventOccurrenceDomainObject) + ->setId(3) + ->setStartDate('2026-09-01 18:00:00'); + + $mail = (new ReflectionClass(AttendeeTicketMail::class))->newInstanceWithoutConstructor(); + Closure::bind(function () use ($event, $attendee, $occurrence): void { + $this->event = $event; + $this->attendee = $attendee; + $this->occurrence = $occurrence; + $this->order = new OrderDomainObject; + $this->organizer = new OrganizerDomainObject; + $this->eventSettings = new EventSettingDomainObject; + $this->renderedTemplate = null; + }, $mail, AttendeeTicketMail::class)(); + + $tickets = $mail->content()->with['tickets']; + + $this->assertSame('🎟️ Your Ticket for Test Event', $mail->envelope()->subject); + $this->assertCount(1, $tickets); + $this->assertSame($attendee, $tickets[0]['attendee']); + $this->assertSame($occurrence, $tickets[0]['occurrence']); + } } diff --git a/backend/tests/Unit/Mail/Attendee/GroupedAttendeeTicketMailTest.php b/backend/tests/Unit/Mail/Attendee/GroupedAttendeeTicketMailTest.php new file mode 100644 index 0000000000..55c1383ca1 --- /dev/null +++ b/backend/tests/Unit/Mail/Attendee/GroupedAttendeeTicketMailTest.php @@ -0,0 +1,166 @@ +firstDate = (new EventOccurrenceDomainObject) + ->setId(1) + ->setStartDate('2026-11-05 18:00:00') + ->setEndDate('2026-11-05 20:00:00'); + $this->secondDate = (new EventOccurrenceDomainObject) + ->setId(2) + ->setStartDate('2026-11-12 18:00:00') + ->setEndDate('2026-11-12 20:00:00') + ->setLabel('Late show'); + + $this->organizer = (new OrganizerDomainObject) + ->setId(1) + ->setName('Organizer') + ->setEmail('organizer@example.com'); + + $this->settings = (new EventSettingDomainObject) + ->setSupportEmail('support@example.com'); + + $this->event = (new EventDomainObject) + ->setId(10) + ->setAccountId(1) + ->setTitle('Jazz Nights') + ->setTimezone('UTC') + ->setEventOccurrences(collect([$this->firstDate, $this->secondDate])); + + $this->order = (new OrderDomainObject) + ->setId(30) + ->setShortId('o_short') + ->setStatus(OrderStatus::COMPLETED->name); + } + + public function test_grouped_mail_lists_every_ticket_with_its_own_date_and_link(): void + { + $attendees = collect([ + $this->attendee(101, 'a_first', $this->firstDate), + $this->attendee(102, 'a_second', $this->secondDate), + ]); + + $html = $this->mail($attendees)->render(); + + $this->assertStringContainsString('Thu, Nov 5, 2026', $html); + $this->assertStringContainsString('Thu, Nov 12, 2026', $html); + $this->assertStringContainsString('Late show', $html); + $this->assertStringContainsString('/product/10/a_first', $html); + $this->assertStringContainsString('/product/10/a_second', $html); + $this->assertSame(2, substr_count($html, 'General Admission')); + } + + public function test_grouped_mail_uses_plural_subject(): void + { + $mail = $this->mail(collect([ + $this->attendee(101, 'a_first', $this->firstDate), + $this->attendee(102, 'a_second', $this->firstDate), + ])); + + $this->assertSame('🎟️ Your Tickets for Jazz Nights', $mail->envelope()->subject); + } + + public function test_single_ticket_mail_keeps_singular_subject_and_one_link(): void + { + $attendee = $this->attendee(101, 'a_first', $this->firstDate); + $mail = new AttendeeTicketMail( + order: $this->order, + attendee: $attendee, + event: $this->event, + eventSettings: $this->settings, + organizer: $this->organizer, + occurrence: $this->firstDate, + ); + + $this->assertSame('🎟️ Your Ticket for Jazz Nights', $mail->envelope()->subject); + $this->assertSame(1, substr_count($mail->render(), '/product/10/a_first')); + } + + public function test_calendar_has_one_entry_per_distinct_date(): void + { + $mail = $this->mail(collect([ + $this->attendee(101, 'a_first', $this->firstDate), + $this->attendee(102, 'a_second', $this->firstDate), + $this->attendee(103, 'a_third', $this->secondDate), + ])); + + $ics = $this->icsFor($mail); + + $this->assertSame(2, substr_count($ics, 'BEGIN:VEVENT')); + $this->assertStringContainsString('UID:event-101', $ics); + $this->assertStringContainsString('UID:event-103', $ics); + $this->assertStringNotContainsString('UID:event-102', $ics); + $this->assertStringContainsString('20261112T180000', $ics); + } + + private function mail(Collection $attendees): AttendeeTicketMail + { + return new AttendeeTicketMail( + order: $this->order, + attendee: $attendees->first(), + event: $this->event, + eventSettings: $this->settings, + organizer: $this->organizer, + occurrence: $attendees->first()->getEventOccurrence(), + attendees: $attendees, + ); + } + + private function attendee(int $id, string $shortId, EventOccurrenceDomainObject $occurrence): AttendeeDomainObject + { + return (new AttendeeDomainObject) + ->setId($id) + ->setShortId($shortId) + ->setFirstName('Sam') + ->setLastName('Buyer') + ->setEmail('buyer@example.com') + ->setEventOccurrenceId($occurrence->getId()) + ->setEventOccurrence($occurrence) + ->setProduct((new ProductDomainObject)->setTitle('General Admission')); + } + + private function icsFor(AttendeeTicketMail $mail): string + { + $attachment = $mail->attachments()[0]; + + $data = null; + $attachment->attachWith( + fn () => null, + function ($resolver) use (&$data) { + $data = $resolver(); + }, + ); + + return $data; + } +} diff --git a/backend/tests/Unit/Mail/Order/OrderSummaryTest.php b/backend/tests/Unit/Mail/Order/OrderSummaryTest.php new file mode 100644 index 0000000000..7f1c578cd5 --- /dev/null +++ b/backend/tests/Unit/Mail/Order/OrderSummaryTest.php @@ -0,0 +1,67 @@ +mailFor([ + $this->occurrence(2, '2026-11-12 18:00:00', 'Late show'), + ])->render(); + + $this->assertStringContainsString('Your order for Jazz Nights on November 12, 2026 at 6:00 PM was successful', $html); + $this->assertStringContainsString('Late show', $html); + $this->assertStringNotContainsString('October 1, 2026', $html); + } + + private function mailFor(array $orderOccurrences): OrderSummary + { + $event = (new EventDomainObject) + ->setId(10) + ->setTitle('Jazz Nights') + ->setTimezone('UTC') + ->setCurrency('USD') + ->setEventOccurrences(collect([ + $this->occurrence(9, '2026-10-01 18:00:00'), + ...$orderOccurrences, + ])); + + $order = (new OrderDomainObject) + ->setId(30) + ->setShortId('o_short') + ->setPublicId('ORD-1') + ->setTotalGross(0) + ->setStatus(OrderStatus::COMPLETED->name) + ->setOrderItems(collect(array_map( + fn (EventOccurrenceDomainObject $occurrence) => (new OrderItemDomainObject)->setEventOccurrence($occurrence), + $orderOccurrences, + ))); + + return new OrderSummary( + order: $order, + event: $event, + organizer: (new OrganizerDomainObject)->setName('Organizer')->setEmail('organizer@example.com'), + eventSettings: (new EventSettingDomainObject)->setSupportEmail('support@example.com'), + invoice: null, + ); + } + + private function occurrence(int $id, string $start, ?string $label = null): EventOccurrenceDomainObject + { + return (new EventOccurrenceDomainObject) + ->setId($id) + ->setStartDate($start) + ->setLabel($label); + } +} diff --git a/backend/tests/Unit/Services/Domain/Email/EmailTokenContextBuilderTest.php b/backend/tests/Unit/Services/Domain/Email/EmailTokenContextBuilderTest.php index a4b3dfa985..60e7836e9c 100644 --- a/backend/tests/Unit/Services/Domain/Email/EmailTokenContextBuilderTest.php +++ b/backend/tests/Unit/Services/Domain/Email/EmailTokenContextBuilderTest.php @@ -233,6 +233,40 @@ public function test_occurrence_tokens_empty_when_no_occurrence(): void $this->assertEquals('', $context['occurrence']['label']); } + public function test_uses_the_orders_only_occurrence_when_none_is_passed(): void + { + $occurrence = (new EventOccurrenceDomainObject) + ->setId(7) + ->setStartDate('2024-03-10 18:00:00') + ->setEndDate('2024-03-10 20:00:00') + ->setLabel('Evening'); + $order = $this->createMockOrder(occurrence: $occurrence); + + $context = $this->contextBuilder->buildOrderConfirmationContext( + $order, $this->createMockEvent(), $this->createMockOrganizer(), $this->createMockEventSettings() + ); + + $this->assertEquals('March 10, 2024', $context['event']['date']); + $this->assertEquals('Evening', $context['occurrence']['label']); + $this->assertEquals('Amazing Event - Evening', $context['event']['title']); + } + + public function test_explicit_occurrence_wins_over_the_orders_occurrences(): void + { + $orderOccurrence = (new EventOccurrenceDomainObject) + ->setId(7) + ->setStartDate('2024-03-10 18:00:00') + ->setEndDate('2024-03-10 20:00:00'); + $order = $this->createMockOrder(occurrence: $orderOccurrence); + + $context = $this->contextBuilder->buildOrderConfirmationContext( + $order, $this->createMockEvent(), $this->createMockOrganizer(), $this->createMockEventSettings(), $this->createMockOccurrence() + ); + + $this->assertEquals('Afternoon Show', $context['occurrence']['label']); + $this->assertNotEquals('March 10, 2024', $context['event']['date']); + } + public function test_event_in_person_location_in_token_context(): void { $order = $this->createMockOrder(); @@ -438,7 +472,7 @@ private function createMockOccurrenceWithLocation(?EventLocationDomainObject $ev ]); } - private function createMockOrder(string $firstName = 'John'): OrderDomainObject + private function createMockOrder(string $firstName = 'John', ?EventOccurrenceDomainObject $occurrence = null): OrderDomainObject { $orderItem = Mockery::mock(OrderItemDomainObject::class, [ 'getProductPriceId' => 123, @@ -462,6 +496,7 @@ private function createMockOrder(string $firstName = 'John'): OrderDomainObject 'getCurrency' => 'USD', 'getLocale' => 'en', 'getSeatLabels' => ['Stalls · A-1', 'Stalls · A-2'], + 'getSingleEventOccurrence' => $occurrence, ]); } diff --git a/backend/tests/Unit/Services/Domain/Email/MailBuilderServiceTest.php b/backend/tests/Unit/Services/Domain/Email/MailBuilderServiceTest.php index 754101386b..0d27e750ac 100644 --- a/backend/tests/Unit/Services/Domain/Email/MailBuilderServiceTest.php +++ b/backend/tests/Unit/Services/Domain/Email/MailBuilderServiceTest.php @@ -2,13 +2,17 @@ namespace Tests\Unit\Services\Domain\Email; +use HiEvents\DomainObjects\AttendeeDomainObject; +use HiEvents\DomainObjects\EmailTemplateDomainObject; use HiEvents\DomainObjects\Enums\PaymentProviders; use HiEvents\DomainObjects\EventDomainObject; +use HiEvents\DomainObjects\EventOccurrenceDomainObject; use HiEvents\DomainObjects\EventSettingDomainObject; use HiEvents\DomainObjects\OrderDomainObject; use HiEvents\DomainObjects\OrganizerDomainObject; use HiEvents\DomainObjects\Status\OrderPaymentStatus; use HiEvents\DomainObjects\Status\OrderStatus; +use HiEvents\Services\Domain\Email\DTO\RenderedEmailTemplateDTO; use HiEvents\Services\Domain\Email\EmailTemplateService; use HiEvents\Services\Domain\Email\EmailTokenContextBuilder; use HiEvents\Services\Domain\Email\MailBuilderService; @@ -72,6 +76,115 @@ public function test_order_summary_blade_fallback_receives_rendered_instructions ); } + public function test_attendees_sharing_an_email_get_one_mail_with_every_ticket_sorted_by_date(): void + { + $emailTemplateService = m::mock(EmailTemplateService::class); + $emailTemplateService->shouldReceive('getTemplateByType')->once()->andReturn(null); + + [$order, $event, $settings, $organizer] = $this->ticketFixtures(); + $later = $this->attendee(1, (new EventOccurrenceDomainObject)->setId(2)->setStartDate('2026-11-12 18:00:00')); + $earlier = $this->attendee(2, (new EventOccurrenceDomainObject)->setId(1)->setStartDate('2026-11-05 18:00:00')); + + $mails = $this->builder($emailTemplateService) + ->buildAttendeeTicketMails(collect([$later, $earlier]), $order, $event, $settings, $organizer); + + $this->assertCount(1, $mails); + $tickets = $mails[0]->content()->with['tickets']; + $this->assertCount(2, $tickets); + $this->assertSame($earlier, $tickets[0]['attendee']); + $this->assertSame($later, $tickets[1]['attendee']); + } + + public function test_custom_ticket_template_sends_one_mail_per_attendee(): void + { + $template = (new EmailTemplateDomainObject) + ->setSubject('Ticket for {{ attendee.name }}') + ->setBody('

{{ ticket.url }}

') + ->setCta(['label' => 'View Ticket', 'url_token' => 'ticket.url']); + + $emailTemplateService = m::mock(EmailTemplateService::class); + $emailTemplateService->shouldReceive('getTemplateByType')->once()->andReturn($template); + $emailTemplateService->shouldReceive('renderTemplate') + ->twice() + ->andReturnUsing(fn ($t, array $context) => new RenderedEmailTemplateDTO( + subject: 'Ticket for '.$context['attendee']['name'], + body: $context['ticket']['url'], + cta: null, + )); + + [$order, $event, $settings, $organizer] = $this->ticketFixtures(); + $first = $this->attendee(1, (new EventOccurrenceDomainObject)->setId(1)->setStartDate('2026-11-05 18:00:00'), 'Ann'); + $second = $this->attendee(2, (new EventOccurrenceDomainObject)->setId(2)->setStartDate('2026-11-12 18:00:00'), 'Bob'); + + $mails = $this->builder($emailTemplateService) + ->buildAttendeeTicketMails(collect([$first, $second]), $order, $event, $settings, $organizer); + + $this->assertCount(2, $mails); + $this->assertSame('Ticket for Ann Buyer', $mails[0]->envelope()->subject); + $this->assertSame('Ticket for Bob Buyer', $mails[1]->envelope()->subject); + } + + public function test_single_attendee_gets_a_single_ticket_mail(): void + { + $emailTemplateService = m::mock(EmailTemplateService::class); + $emailTemplateService->shouldReceive('getTemplateByType')->once()->andReturn(null); + + [$order, $event, $settings, $organizer] = $this->ticketFixtures(); + $attendee = $this->attendee(1, (new EventOccurrenceDomainObject)->setId(1)->setStartDate('2026-11-05 18:00:00')); + + $mails = $this->builder($emailTemplateService) + ->buildAttendeeTicketMails(collect([$attendee]), $order, $event, $settings, $organizer); + + $this->assertCount(1, $mails); + $this->assertSame('🎟️ Your Ticket for Summer Session', $mails[0]->envelope()->subject); + } + + private function builder(EmailTemplateService $emailTemplateService): MailBuilderService + { + return new MailBuilderService( + $emailTemplateService, + app(EmailTokenContextBuilder::class), + app(OfflinePaymentInstructionsRenderService::class), + ); + } + + private function ticketFixtures(): array + { + $organizer = (new OrganizerDomainObject)->setId(1)->setName('Organizer')->setEmail('organizer@example.com'); + $settings = (new EventSettingDomainObject)->setSupportEmail('support@example.com'); + $event = (new EventDomainObject) + ->setId(10) + ->setAccountId(1) + ->setTitle('Summer Session') + ->setCurrency('GBP') + ->setTimezone('UTC'); + $order = (new OrderDomainObject) + ->setId(30) + ->setShortId('o_short') + ->setPublicId('ORD-1') + ->setTotalGross(0) + ->setCurrency('GBP') + ->setLocale('en') + ->setPaymentProvider(PaymentProviders::STRIPE->value) + ->setCreatedAt('2026-08-01 12:00:00') + ->setStatus(OrderStatus::COMPLETED->name) + ->setOrderItems(collect()); + + return [$order, $event, $settings, $organizer]; + } + + private function attendee(int $id, EventOccurrenceDomainObject $occurrence, string $firstName = 'Sam'): AttendeeDomainObject + { + return (new AttendeeDomainObject) + ->setId($id) + ->setShortId('a_'.$id) + ->setFirstName($firstName) + ->setLastName('Buyer') + ->setEmail('buyer@example.com') + ->setEventOccurrenceId($occurrence->getId()) + ->setEventOccurrence($occurrence); + } + protected function tearDown(): void { m::close(); diff --git a/backend/tests/Unit/Services/Domain/Mail/SendOrderDetailsServiceTest.php b/backend/tests/Unit/Services/Domain/Mail/SendOrderDetailsServiceTest.php new file mode 100644 index 0000000000..d086d375d9 --- /dev/null +++ b/backend/tests/Unit/Services/Domain/Mail/SendOrderDetailsServiceTest.php @@ -0,0 +1,110 @@ +setId(30) + ->setEventId(10) + ->setEmail('buyer@example.com') + ->setStatus(OrderStatus::COMPLETED->name) + ->setOrderItems(collect()) + ->setAttendees(collect([ + $this->attendee(1, 'buyer@example.com'), + $this->attendee(2, 'friend@example.com'), + $this->attendee(3, 'buyer@example.com'), + $this->attendee(4, null), + $this->attendee(5, 'buyer@example.com', AttendeeStatus::CANCELLED), + $this->attendee(6, 'buyer@example.com', AttendeeStatus::AWAITING_PAYMENT), + ])); + + $sentGroups = []; + $this->sendAttendeeTicketService + ->shouldReceive('sendTickets') + ->twice() + ->andReturnUsing(function (OrderDomainObject $order, Collection $attendees) use (&$sentGroups): void { + $sentGroups[] = $attendees->map(fn (AttendeeDomainObject $a) => $a->getId())->all(); + }); + + $this->service($order)->sendOrderSummaryAndTicketEmails($order); + + $this->assertSame([[1, 3, 6], [2]], $sentGroups); + } + + private function service(OrderDomainObject $order): SendOrderDetailsService + { + $organizer = (new OrganizerDomainObject)->setId(1)->setEmail('organizer@example.com'); + $event = (new EventDomainObject) + ->setId(10) + ->setOrganizer($organizer) + ->setEventSettings((new EventSettingDomainObject)->setNotifyOrganizerOfNewOrders(false)); + + $orderRepository = m::mock(OrderRepositoryInterface::class); + $orderRepository->shouldReceive('loadRelation')->andReturnSelf(); + $orderRepository->shouldReceive('findById')->andReturn($order); + + $eventRepository = m::mock(EventRepositoryInterface::class); + $eventRepository->shouldReceive('loadRelation')->andReturnSelf(); + $eventRepository->shouldReceive('findById')->andReturn($event); + + $mailer = m::mock(Mailer::class); + $mailer->shouldReceive('to')->andReturnSelf(); + $mailer->shouldReceive('locale')->andReturnSelf(); + $mailer->shouldReceive('send'); + + $mailBuilderService = m::mock(MailBuilderService::class); + $mailBuilderService->shouldReceive('buildOrderSummaryMail')->andReturn(m::mock(OrderSummary::class)); + + return new SendOrderDetailsService( + $eventRepository, + $orderRepository, + $mailer, + $this->sendAttendeeTicketService, + $mailBuilderService, + ); + } + + private function attendee(int $id, ?string $email, AttendeeStatus $status = AttendeeStatus::ACTIVE): AttendeeDomainObject + { + return (new AttendeeDomainObject) + ->setId($id) + ->setEmail($email) + ->setStatus($status->name); + } + + protected function setUp(): void + { + parent::setUp(); + $this->sendAttendeeTicketService = m::mock(SendAttendeeTicketService::class); + } + + protected function tearDown(): void + { + m::close(); + parent::tearDown(); + } +} diff --git a/e2e/tests/checkout/per-order-details-checkout.spec.ts b/e2e/tests/checkout/per-order-details-checkout.spec.ts index 1db9439617..43a69fbe86 100644 --- a/e2e/tests/checkout/per-order-details-checkout.spec.ts +++ b/e2e/tests/checkout/per-order-details-checkout.spec.ts @@ -24,7 +24,13 @@ test.describe('per-order attendee details checkout', () => { await checkout.completeFreeOrder(); await expect(page.getByText(`You're going to ${event.title}`)).toBeVisible(); - await mailpit.waitForMessage(buyerEmail); + const ticketEmail = await mailpit.waitForMessage(buyerEmail, { subjectContains: 'Your Tickets for' }); + await mailpit.waitForMessage(buyerEmail, { subjectContains: 'Your Order is Confirmed' }); + expect(await mailpit.search(buyerEmail)).toHaveLength(2); + + const ticketLinks = (await mailpit.getLinks(ticketEmail.ID)) + .filter((link) => new RegExp(`/product/${event.eventId}/[^/]+$`).test(link)); + expect(new Set(ticketLinks).size).toBe(2); const attendees = new AttendeePage(authedPage); await attendees.goto(event.eventId);