From fde36ac64eb076f51085aeb2ea1782351d8df015 Mon Sep 17 00:00:00 2001 From: Dave Earley Date: Fri, 16 Aug 2024 22:17:54 -0700 Subject: [PATCH 1/3] Add Check-in lists --- .../app/DataTransferObjects/ErrorBagDTO.php | 25 ++ .../AttendeeCheckInDomainObject.php | 19 + .../DomainObjects/AttendeeDomainObject.php | 13 + .../DomainObjects/CheckInListDomainObject.php | 137 +++++++ .../AttendeeCheckInDomainObjectAbstract.php | 146 ++++++++ .../AttendeeDomainObjectAbstract.php | 42 --- .../CheckInListDomainObjectAbstract.php | 160 ++++++++ .../Generated/OrderDomainObjectAbstract.php | 28 +- .../TicketCheckInListDomainObjectAbstract.php | 76 ++++ ...TicketsCheckInListDomainObjectAbstract.php | 76 ++++ .../TicketCheckInListDomainObject.php | 7 + .../TicketsCheckInListDomainObject.php | 7 + backend/app/Helper/DateHelper.php | 10 + backend/app/Helper/IdHelper.php | 12 +- backend/app/Http/Actions/BaseAction.php | 22 +- .../CheckInLists/CreateCheckInListAction.php | 47 +++ .../CheckInLists/DeleteCheckInListAction.php | 29 ++ .../CheckInLists/EditCheckInListAction.php | 10 + .../CheckInLists/GetCheckInListAction.php | 33 ++ .../CheckInLists/GetCheckInListsAction.php | 37 ++ .../CreateAttendeeCheckInPublicAction.php | 47 +++ .../DeleteAttendeeCheckInPublicAction.php | 42 +++ .../GetCheckInListAttendeesPublicAction.php | 32 ++ .../Public/GetCheckInListPublicAction.php | 27 ++ .../CheckInLists/UpdateCheckInListAction.php | 52 +++ .../UpsertCapacityAssignmentRequest.php | 2 +- .../CreateAttendeeCheckInPublicRequest.php | 16 + .../CheckInList/UpsertCheckInListRequest.php | 40 ++ backend/app/Models/Attendee.php | 6 + backend/app/Models/AttendeeCheckIn.php | 35 ++ backend/app/Models/CheckInList.php | 32 ++ backend/app/Models/Ticket.php | 5 + .../Providers/RepositoryServiceProvider.php | 6 + .../DTO/CheckedInAttendeesCountDTO.php | 16 + .../Eloquent/AttendeeCheckInRepository.php | 20 + .../Eloquent/AttendeeRepository.php | 54 ++- .../Repository/Eloquent/BaseRepository.php | 18 + .../Eloquent/CheckInListRepository.php | 130 +++++++ .../Repository/Eloquent/TicketRepository.php | 46 ++- .../AttendeeCheckInRepositoryInterface.php | 14 + .../AttendeeRepositoryInterface.php | 6 +- .../CheckInListRepositoryInterface.php | 26 ++ .../Interfaces/RepositoryInterface.php | 13 + .../Interfaces/TicketRepositoryInterface.php | 17 +- .../app/Resources/Account/AccountResource.php | 2 + .../Resources/Attendee/AttendeeResource.php | 2 - .../Attendee/AttendeeResourcePublic.php | 1 - .../AttendeeWithCheckInPublicResource.php | 31 ++ .../AttendeeCheckInPublicResource.php | 23 ++ .../CheckInList/CheckInListResource.php | 34 ++ .../CheckInList/CheckInListResourcePublic.php | 36 ++ .../UpdateCapacityAssignmentService.php | 5 +- .../CheckInList/CheckInListDataService.php | 88 +++++ .../CheckInListTicketAssociationService.php | 53 +++ .../CreateAttendeeCheckInService.php | 100 +++++ .../CreateAttendeeCheckInsResponseDTO.php | 17 + .../CheckInList/CreateCheckInListService.php | 60 +++ .../DeleteAttendeeCheckInService.php | 48 +++ .../CheckInList/UpdateCheckInListService.php | 66 ++++ .../Domain/Event/CreateEventService.php | 2 +- .../Domain/Order/OrderManagementService.php | 5 +- .../Handlers/Account/CreateAccountHandler.php | 2 +- .../Attendee/CreateAttendeeHandler.php | 8 +- .../CheckInList/CreateCheckInListHandler.php | 35 ++ .../CheckInList/DTO/GetCheckInListsDTO.php | 16 + .../CheckInList/DTO/UpsertCheckInListDTO.php | 20 + .../CheckInList/DeleteCheckInListHandler.php | 33 ++ .../CheckInList/GetCheckInListHandler.php | 41 ++ .../CheckInList/GetCheckInListsHandler.php | 52 +++ .../CreateAttendeeCheckInPublicHandler.php | 41 ++ .../DTO/CreateAttendeeCheckInPublicDTO.php | 16 + .../DTO/DeleteAttendeeCheckInPublicDTO.php | 16 + .../DeleteAttendeeCheckInPublicHandler.php | 35 ++ .../GetCheckInListAttendeesPublicHandler.php | 21 ++ .../Public/GetCheckInListPublicHandler.php | 40 ++ .../CheckInList/UpdateCheckInlistHandler.php | 36 ++ .../Handlers/Order/CompleteOrderHandler.php | 2 +- backend/database/factories/OrderFactory.php | 2 +- ...08_032637_create_check_in_lists_tables.php | 101 +++++ backend/lang/de.json | 14 +- backend/lang/es.json | 14 +- backend/lang/fr.json | 14 +- backend/lang/pt-br.json | 14 +- backend/lang/pt.json | 14 +- backend/lang/ru.json | 14 +- backend/lang/zh-cn.json | 14 +- backend/routes/api.php | 28 ++ .../public/blank-slate/check-in-lists.svg | 41 ++ frontend/src/api/check-in-list.client.ts | 32 ++ frontend/src/api/check-in.client.ts | 32 ++ frontend/src/api/client.ts | 3 +- .../common/AttendeeCheckInTable/QrScanner.tsx | 10 +- .../CheckInListList.module.scss | 95 +++++ .../common/CheckInListList/index.tsx | 187 ++++++++++ .../src/components/common/Countdown/index.tsx | 19 +- .../common/Header/Header.module.scss | 1 + .../src/components/common/Header/index.tsx | 17 +- .../forms/CheckInListForm/index.tsx | 70 ++++ .../layouts/CheckIn/CheckIn.module.scss | 111 ++++++ .../src/components/layouts/CheckIn/index.tsx | 351 ++++++++++++++++++ .../layouts/DefaultLayout/index.tsx | 5 +- .../src/components/layouts/Event/index.tsx | 3 - .../modals/CreateCheckInListModal/index.tsx | 85 +++++ .../modals/CreatePromoCodeModal/index.tsx | 3 +- .../modals/EditCheckInListModal/index.tsx | 97 +++++ .../routes/account/ManageAccount/index.tsx | 7 +- .../routes/event/CheckInLists/index.tsx | 69 ++++ .../src/components/routes/event/check-in.tsx | 5 - frontend/src/locales/de.js | 2 +- frontend/src/locales/de.po | 246 ++++++++++-- frontend/src/locales/en.js | 2 +- frontend/src/locales/en.po | 246 ++++++++++-- frontend/src/locales/es.js | 2 +- frontend/src/locales/es.po | 246 ++++++++++-- frontend/src/locales/fr.js | 2 +- frontend/src/locales/fr.po | 246 ++++++++++-- frontend/src/locales/pt-br.js | 2 +- frontend/src/locales/pt-br.po | 246 ++++++++++-- frontend/src/locales/pt.js | 2 +- frontend/src/locales/pt.po | 246 ++++++++++-- frontend/src/locales/ru.js | 2 +- frontend/src/locales/ru.po | 246 ++++++++++-- frontend/src/locales/zh-cn.js | 2 +- frontend/src/locales/zh-cn.po | 246 ++++++++++-- .../mutations/useCreateCapacityAssignment.ts | 3 +- .../src/mutations/useCreateCheckInList.ts | 19 + .../src/mutations/useCreateCheckInPublic.ts | 35 ++ .../src/mutations/useDeleteCheckInList.ts | 19 + .../src/mutations/useDeleteCheckInPublic.ts | 38 ++ frontend/src/mutations/useEditCheckInList.ts | 33 ++ frontend/src/queries/useGetCheckInList.ts | 15 + .../useGetCheckInListAttendeesPublic.ts | 15 + .../src/queries/useGetCheckInListPublic.ts | 15 + frontend/src/queries/useGetCheckInLists.ts | 14 + frontend/src/router.tsx | 10 +- frontend/src/types.ts | 34 ++ 136 files changed, 5987 insertions(+), 344 deletions(-) create mode 100644 backend/app/DataTransferObjects/ErrorBagDTO.php create mode 100644 backend/app/DomainObjects/AttendeeCheckInDomainObject.php create mode 100644 backend/app/DomainObjects/CheckInListDomainObject.php create mode 100644 backend/app/DomainObjects/Generated/AttendeeCheckInDomainObjectAbstract.php create mode 100644 backend/app/DomainObjects/Generated/CheckInListDomainObjectAbstract.php create mode 100644 backend/app/DomainObjects/Generated/TicketCheckInListDomainObjectAbstract.php create mode 100644 backend/app/DomainObjects/Generated/TicketsCheckInListDomainObjectAbstract.php create mode 100644 backend/app/DomainObjects/TicketCheckInListDomainObject.php create mode 100644 backend/app/DomainObjects/TicketsCheckInListDomainObject.php create mode 100644 backend/app/Http/Actions/CheckInLists/CreateCheckInListAction.php create mode 100644 backend/app/Http/Actions/CheckInLists/DeleteCheckInListAction.php create mode 100644 backend/app/Http/Actions/CheckInLists/EditCheckInListAction.php create mode 100644 backend/app/Http/Actions/CheckInLists/GetCheckInListAction.php create mode 100644 backend/app/Http/Actions/CheckInLists/GetCheckInListsAction.php create mode 100644 backend/app/Http/Actions/CheckInLists/Public/CreateAttendeeCheckInPublicAction.php create mode 100644 backend/app/Http/Actions/CheckInLists/Public/DeleteAttendeeCheckInPublicAction.php create mode 100644 backend/app/Http/Actions/CheckInLists/Public/GetCheckInListAttendeesPublicAction.php create mode 100644 backend/app/Http/Actions/CheckInLists/Public/GetCheckInListPublicAction.php create mode 100644 backend/app/Http/Actions/CheckInLists/UpdateCheckInListAction.php create mode 100644 backend/app/Http/Request/CheckInList/CreateAttendeeCheckInPublicRequest.php create mode 100644 backend/app/Http/Request/CheckInList/UpsertCheckInListRequest.php create mode 100644 backend/app/Models/AttendeeCheckIn.php create mode 100644 backend/app/Models/CheckInList.php create mode 100644 backend/app/Repository/DTO/CheckedInAttendeesCountDTO.php create mode 100644 backend/app/Repository/Eloquent/AttendeeCheckInRepository.php create mode 100644 backend/app/Repository/Eloquent/CheckInListRepository.php create mode 100644 backend/app/Repository/Interfaces/AttendeeCheckInRepositoryInterface.php create mode 100644 backend/app/Repository/Interfaces/CheckInListRepositoryInterface.php create mode 100644 backend/app/Resources/Attendee/AttendeeWithCheckInPublicResource.php create mode 100644 backend/app/Resources/CheckInList/AttendeeCheckInPublicResource.php create mode 100644 backend/app/Resources/CheckInList/CheckInListResource.php create mode 100644 backend/app/Resources/CheckInList/CheckInListResourcePublic.php create mode 100644 backend/app/Services/Domain/CheckInList/CheckInListDataService.php create mode 100644 backend/app/Services/Domain/CheckInList/CheckInListTicketAssociationService.php create mode 100644 backend/app/Services/Domain/CheckInList/CreateAttendeeCheckInService.php create mode 100644 backend/app/Services/Domain/CheckInList/CreateAttendeeCheckInsResponseDTO.php create mode 100644 backend/app/Services/Domain/CheckInList/CreateCheckInListService.php create mode 100644 backend/app/Services/Domain/CheckInList/DeleteAttendeeCheckInService.php create mode 100644 backend/app/Services/Domain/CheckInList/UpdateCheckInListService.php create mode 100644 backend/app/Services/Handlers/CheckInList/CreateCheckInListHandler.php create mode 100644 backend/app/Services/Handlers/CheckInList/DTO/GetCheckInListsDTO.php create mode 100644 backend/app/Services/Handlers/CheckInList/DTO/UpsertCheckInListDTO.php create mode 100644 backend/app/Services/Handlers/CheckInList/DeleteCheckInListHandler.php create mode 100644 backend/app/Services/Handlers/CheckInList/GetCheckInListHandler.php create mode 100644 backend/app/Services/Handlers/CheckInList/GetCheckInListsHandler.php create mode 100644 backend/app/Services/Handlers/CheckInList/Public/CreateAttendeeCheckInPublicHandler.php create mode 100644 backend/app/Services/Handlers/CheckInList/Public/DTO/CreateAttendeeCheckInPublicDTO.php create mode 100644 backend/app/Services/Handlers/CheckInList/Public/DTO/DeleteAttendeeCheckInPublicDTO.php create mode 100644 backend/app/Services/Handlers/CheckInList/Public/DeleteAttendeeCheckInPublicHandler.php create mode 100644 backend/app/Services/Handlers/CheckInList/Public/GetCheckInListAttendeesPublicHandler.php create mode 100644 backend/app/Services/Handlers/CheckInList/Public/GetCheckInListPublicHandler.php create mode 100644 backend/app/Services/Handlers/CheckInList/UpdateCheckInlistHandler.php create mode 100644 backend/database/migrations/2024_08_08_032637_create_check_in_lists_tables.php create mode 100644 frontend/public/blank-slate/check-in-lists.svg create mode 100644 frontend/src/api/check-in-list.client.ts create mode 100644 frontend/src/api/check-in.client.ts create mode 100644 frontend/src/components/common/CheckInListList/CheckInListList.module.scss create mode 100644 frontend/src/components/common/CheckInListList/index.tsx create mode 100644 frontend/src/components/forms/CheckInListForm/index.tsx create mode 100644 frontend/src/components/layouts/CheckIn/CheckIn.module.scss create mode 100644 frontend/src/components/layouts/CheckIn/index.tsx create mode 100644 frontend/src/components/modals/CreateCheckInListModal/index.tsx create mode 100644 frontend/src/components/modals/EditCheckInListModal/index.tsx create mode 100644 frontend/src/components/routes/event/CheckInLists/index.tsx delete mode 100644 frontend/src/components/routes/event/check-in.tsx create mode 100644 frontend/src/mutations/useCreateCheckInList.ts create mode 100644 frontend/src/mutations/useCreateCheckInPublic.ts create mode 100644 frontend/src/mutations/useDeleteCheckInList.ts create mode 100644 frontend/src/mutations/useDeleteCheckInPublic.ts create mode 100644 frontend/src/mutations/useEditCheckInList.ts create mode 100644 frontend/src/queries/useGetCheckInList.ts create mode 100644 frontend/src/queries/useGetCheckInListAttendeesPublic.ts create mode 100644 frontend/src/queries/useGetCheckInListPublic.ts create mode 100644 frontend/src/queries/useGetCheckInLists.ts diff --git a/backend/app/DataTransferObjects/ErrorBagDTO.php b/backend/app/DataTransferObjects/ErrorBagDTO.php new file mode 100644 index 00000000..0dfac120 --- /dev/null +++ b/backend/app/DataTransferObjects/ErrorBagDTO.php @@ -0,0 +1,25 @@ + + */ + public array $errors = [], + ) + { + } + + public function addError(string $key, string $message): void + { + $this->errors[$key] = $message; + } + + public function toArray(array $without = []): array + { + return $this->errors; + } +} diff --git a/backend/app/DomainObjects/AttendeeCheckInDomainObject.php b/backend/app/DomainObjects/AttendeeCheckInDomainObject.php new file mode 100644 index 00000000..afe1ea6f --- /dev/null +++ b/backend/app/DomainObjects/AttendeeCheckInDomainObject.php @@ -0,0 +1,19 @@ +attendee; + } + + public function setAttendee(AttendeeDomainObject $attendee): self + { + $this->attendee = $attendee; + return $this; + } +} diff --git a/backend/app/DomainObjects/AttendeeDomainObject.php b/backend/app/DomainObjects/AttendeeDomainObject.php index 302ec250..a28ad7c5 100644 --- a/backend/app/DomainObjects/AttendeeDomainObject.php +++ b/backend/app/DomainObjects/AttendeeDomainObject.php @@ -16,6 +16,8 @@ class AttendeeDomainObject extends Generated\AttendeeDomainObjectAbstract implem /** @var Collection|null */ public ?Collection $questionAndAnswerViews = null; + public ?AttendeeCheckInDomainObject $checkIn = null; + public static function getDefaultSort(): string { return self::CREATED_AT; @@ -99,4 +101,15 @@ public function getQuestionAndAnswerViews(): ?Collection { return $this->questionAndAnswerViews; } + + public function setCheckIn(?AttendeeCheckInDomainObject $checkIn): AttendeeDomainObject + { + $this->checkIn = $checkIn; + return $this; + } + + public function getCheckIn(): ?AttendeeCheckInDomainObject + { + return $this->checkIn; + } } diff --git a/backend/app/DomainObjects/CheckInListDomainObject.php b/backend/app/DomainObjects/CheckInListDomainObject.php new file mode 100644 index 00000000..30364989 --- /dev/null +++ b/backend/app/DomainObjects/CheckInListDomainObject.php @@ -0,0 +1,137 @@ + [ + 'asc' => __('Name A-Z'), + 'desc' => __('Name Z-A'), + ], + self::EXPIRES_AT => [ + 'asc' => __('Expires soonest'), + 'desc' => __('Expires latest'), + ], + self::CREATED_AT => [ + 'asc' => __('Oldest first'), + 'desc' => __('Newest first'), + ], + self::UPDATED_AT => [ + 'asc' => __('Updated oldest first'), + 'desc' => __('Updated newest first'), + ], + ] + ); + } + + public function getTickets(): ?Collection + { + return $this->tickets; + } + + public function setTickets(?Collection $tickets): static + { + $this->tickets = $tickets; + + return $this; + } + + public function getEvent(): ?EventDomainObject + { + return $this->event; + } + + public function setEvent(?EventDomainObject $event): static + { + $this->event = $event; + + return $this; + } + + public function isExpired(string $timezone): bool + { + if ($this->getExpiresAt() === null) { + return false; + } + $endDate = Carbon::parse($this->getExpiresAt()); + $endDate->setTimezone($timezone); + + return $endDate->isPast(); + } + + public function isActivated(string $timezone): bool + { + if ($this->getActivatesAt() === null) { + return true; + } + $startDate = Carbon::parse($this->getActivatesAt()); + $startDate->setTimezone($timezone); + + return $startDate->isPast(); + } + + public function getCheckedInCount(): ?int + { + return $this->checkedInCount; + } + + public function setCheckedInCount(?int $checkedInCount): static + { + $this->checkedInCount = $checkedInCount ?? 0; + + return $this; + } + + public function getTotalAttendeesCount(): ?int + { + return $this->totalAttendeesCount; + } + + public function setTotalAttendeesCount(?int $totalAttendeesCount): static + { + $this->totalAttendeesCount = $totalAttendeesCount ?? 0; + + return $this; + } + + public function getTimezone(): ?string + { + return $this->timezone; + } + + public function setTimezone(?string $timezone): static + { + $this->timezone = $timezone; + + return $this; + } +} diff --git a/backend/app/DomainObjects/Generated/AttendeeCheckInDomainObjectAbstract.php b/backend/app/DomainObjects/Generated/AttendeeCheckInDomainObjectAbstract.php new file mode 100644 index 00000000..d092b3fc --- /dev/null +++ b/backend/app/DomainObjects/Generated/AttendeeCheckInDomainObjectAbstract.php @@ -0,0 +1,146 @@ + $this->id ?? null, + 'check_in_list_id' => $this->check_in_list_id ?? null, + 'ticket_id' => $this->ticket_id ?? null, + 'attendee_id' => $this->attendee_id ?? null, + 'short_id' => $this->short_id ?? null, + 'ip_address' => $this->ip_address ?? null, + 'deleted_at' => $this->deleted_at ?? null, + 'created_at' => $this->created_at ?? null, + 'updated_at' => $this->updated_at ?? null, + ]; + } + + public function setId(int $id): self + { + $this->id = $id; + return $this; + } + + public function getId(): int + { + return $this->id; + } + + public function setCheckInListId(int $check_in_list_id): self + { + $this->check_in_list_id = $check_in_list_id; + return $this; + } + + public function getCheckInListId(): int + { + return $this->check_in_list_id; + } + + public function setTicketId(int $ticket_id): self + { + $this->ticket_id = $ticket_id; + return $this; + } + + public function getTicketId(): int + { + return $this->ticket_id; + } + + public function setAttendeeId(int $attendee_id): self + { + $this->attendee_id = $attendee_id; + return $this; + } + + public function getAttendeeId(): int + { + return $this->attendee_id; + } + + public function setShortId(string $short_id): self + { + $this->short_id = $short_id; + return $this; + } + + public function getShortId(): string + { + return $this->short_id; + } + + public function setIpAddress(string $ip_address): self + { + $this->ip_address = $ip_address; + return $this; + } + + public function getIpAddress(): string + { + return $this->ip_address; + } + + public function setDeletedAt(?string $deleted_at): self + { + $this->deleted_at = $deleted_at; + return $this; + } + + public function getDeletedAt(): ?string + { + return $this->deleted_at; + } + + public function setCreatedAt(?string $created_at): self + { + $this->created_at = $created_at; + return $this; + } + + public function getCreatedAt(): ?string + { + return $this->created_at; + } + + public function setUpdatedAt(?string $updated_at): self + { + $this->updated_at = $updated_at; + return $this; + } + + public function getUpdatedAt(): ?string + { + return $this->updated_at; + } +} diff --git a/backend/app/DomainObjects/Generated/AttendeeDomainObjectAbstract.php b/backend/app/DomainObjects/Generated/AttendeeDomainObjectAbstract.php index 59b052de..de65908c 100644 --- a/backend/app/DomainObjects/Generated/AttendeeDomainObjectAbstract.php +++ b/backend/app/DomainObjects/Generated/AttendeeDomainObjectAbstract.php @@ -14,8 +14,6 @@ abstract class AttendeeDomainObjectAbstract extends \HiEvents\DomainObjects\Abst final public const ORDER_ID = 'order_id'; final public const TICKET_ID = 'ticket_id'; final public const EVENT_ID = 'event_id'; - final public const CHECKED_IN_BY = 'checked_in_by'; - final public const CHECKED_OUT_BY = 'checked_out_by'; final public const TICKET_PRICE_ID = 'ticket_price_id'; final public const SHORT_ID = 'short_id'; final public const FIRST_NAME = 'first_name'; @@ -23,7 +21,6 @@ abstract class AttendeeDomainObjectAbstract extends \HiEvents\DomainObjects\Abst final public const EMAIL = 'email'; final public const PUBLIC_ID = 'public_id'; final public const STATUS = 'status'; - final public const CHECKED_IN_AT = 'checked_in_at'; final public const CREATED_AT = 'created_at'; final public const UPDATED_AT = 'updated_at'; final public const DELETED_AT = 'deleted_at'; @@ -33,8 +30,6 @@ abstract class AttendeeDomainObjectAbstract extends \HiEvents\DomainObjects\Abst protected int $order_id; protected int $ticket_id; protected int $event_id; - protected ?int $checked_in_by = null; - protected ?int $checked_out_by = null; protected int $ticket_price_id; protected string $short_id; protected string $first_name = ''; @@ -42,7 +37,6 @@ abstract class AttendeeDomainObjectAbstract extends \HiEvents\DomainObjects\Abst protected string $email; protected string $public_id; protected string $status; - protected ?string $checked_in_at = null; protected string $created_at; protected string $updated_at; protected ?string $deleted_at = null; @@ -55,8 +49,6 @@ public function toArray(): array 'order_id' => $this->order_id ?? null, 'ticket_id' => $this->ticket_id ?? null, 'event_id' => $this->event_id ?? null, - 'checked_in_by' => $this->checked_in_by ?? null, - 'checked_out_by' => $this->checked_out_by ?? null, 'ticket_price_id' => $this->ticket_price_id ?? null, 'short_id' => $this->short_id ?? null, 'first_name' => $this->first_name ?? null, @@ -64,7 +56,6 @@ public function toArray(): array 'email' => $this->email ?? null, 'public_id' => $this->public_id ?? null, 'status' => $this->status ?? null, - 'checked_in_at' => $this->checked_in_at ?? null, 'created_at' => $this->created_at ?? null, 'updated_at' => $this->updated_at ?? null, 'deleted_at' => $this->deleted_at ?? null, @@ -116,28 +107,6 @@ public function getEventId(): int return $this->event_id; } - public function setCheckedInBy(?int $checked_in_by): self - { - $this->checked_in_by = $checked_in_by; - return $this; - } - - public function getCheckedInBy(): ?int - { - return $this->checked_in_by; - } - - public function setCheckedOutBy(?int $checked_out_by): self - { - $this->checked_out_by = $checked_out_by; - return $this; - } - - public function getCheckedOutBy(): ?int - { - return $this->checked_out_by; - } - public function setTicketPriceId(int $ticket_price_id): self { $this->ticket_price_id = $ticket_price_id; @@ -215,17 +184,6 @@ public function getStatus(): string return $this->status; } - public function setCheckedInAt(?string $checked_in_at): self - { - $this->checked_in_at = $checked_in_at; - return $this; - } - - public function getCheckedInAt(): ?string - { - return $this->checked_in_at; - } - public function setCreatedAt(string $created_at): self { $this->created_at = $created_at; diff --git a/backend/app/DomainObjects/Generated/CheckInListDomainObjectAbstract.php b/backend/app/DomainObjects/Generated/CheckInListDomainObjectAbstract.php new file mode 100644 index 00000000..3ce9ebb1 --- /dev/null +++ b/backend/app/DomainObjects/Generated/CheckInListDomainObjectAbstract.php @@ -0,0 +1,160 @@ + $this->id ?? null, + 'event_id' => $this->event_id ?? null, + 'short_id' => $this->short_id ?? null, + 'name' => $this->name ?? null, + 'description' => $this->description ?? null, + 'expires_at' => $this->expires_at ?? null, + 'activates_at' => $this->activates_at ?? null, + 'deleted_at' => $this->deleted_at ?? null, + 'created_at' => $this->created_at ?? null, + 'updated_at' => $this->updated_at ?? null, + ]; + } + + public function setId(int $id): self + { + $this->id = $id; + return $this; + } + + public function getId(): int + { + return $this->id; + } + + public function setEventId(int $event_id): self + { + $this->event_id = $event_id; + return $this; + } + + public function getEventId(): int + { + return $this->event_id; + } + + public function setShortId(string $short_id): self + { + $this->short_id = $short_id; + return $this; + } + + public function getShortId(): string + { + return $this->short_id; + } + + public function setName(string $name): self + { + $this->name = $name; + return $this; + } + + public function getName(): string + { + return $this->name; + } + + public function setDescription(?string $description): self + { + $this->description = $description; + return $this; + } + + public function getDescription(): ?string + { + return $this->description; + } + + public function setExpiresAt(?string $expires_at): self + { + $this->expires_at = $expires_at; + return $this; + } + + public function getExpiresAt(): ?string + { + return $this->expires_at; + } + + public function setActivatesAt(?string $activates_at): self + { + $this->activates_at = $activates_at; + return $this; + } + + public function getActivatesAt(): ?string + { + return $this->activates_at; + } + + public function setDeletedAt(?string $deleted_at): self + { + $this->deleted_at = $deleted_at; + return $this; + } + + public function getDeletedAt(): ?string + { + return $this->deleted_at; + } + + public function setCreatedAt(?string $created_at): self + { + $this->created_at = $created_at; + return $this; + } + + public function getCreatedAt(): ?string + { + return $this->created_at; + } + + public function setUpdatedAt(?string $updated_at): self + { + $this->updated_at = $updated_at; + return $this; + } + + public function getUpdatedAt(): ?string + { + return $this->updated_at; + } +} diff --git a/backend/app/DomainObjects/Generated/OrderDomainObjectAbstract.php b/backend/app/DomainObjects/Generated/OrderDomainObjectAbstract.php index b9d645f1..939ca2cd 100644 --- a/backend/app/DomainObjects/Generated/OrderDomainObjectAbstract.php +++ b/backend/app/DomainObjects/Generated/OrderDomainObjectAbstract.php @@ -24,6 +24,7 @@ abstract class OrderDomainObjectAbstract extends \HiEvents\DomainObjects\Abstrac final public const STATUS = 'status'; final public const PAYMENT_STATUS = 'payment_status'; final public const REFUND_STATUS = 'refund_status'; + final public const RESERVED_UNTIL = 'reserved_until'; final public const IS_MANUALLY_CREATED = 'is_manually_created'; final public const SESSION_ID = 'session_id'; final public const PUBLIC_ID = 'public_id'; @@ -38,7 +39,6 @@ abstract class OrderDomainObjectAbstract extends \HiEvents\DomainObjects\Abstrac final public const TOTAL_TAX = 'total_tax'; final public const TOTAL_FEE = 'total_fee'; final public const LOCALE = 'locale'; - final public const RESERVED_UNTIL = 'reserved_until'; protected int $id; protected int $event_id; @@ -54,6 +54,7 @@ abstract class OrderDomainObjectAbstract extends \HiEvents\DomainObjects\Abstrac protected string $status; protected ?string $payment_status = null; protected ?string $refund_status = null; + protected ?string $reserved_until = null; protected bool $is_manually_created = false; protected ?string $session_id = null; protected string $public_id; @@ -68,7 +69,6 @@ abstract class OrderDomainObjectAbstract extends \HiEvents\DomainObjects\Abstrac protected float $total_tax = 0.0; protected float $total_fee = 0.0; protected string $locale = 'en'; - protected ?string $reserved_until = null; public function toArray(): array { @@ -87,6 +87,7 @@ public function toArray(): array 'status' => $this->status ?? null, 'payment_status' => $this->payment_status ?? null, 'refund_status' => $this->refund_status ?? null, + 'reserved_until' => $this->reserved_until ?? null, 'is_manually_created' => $this->is_manually_created ?? null, 'session_id' => $this->session_id ?? null, 'public_id' => $this->public_id ?? null, @@ -101,7 +102,6 @@ public function toArray(): array 'total_tax' => $this->total_tax ?? null, 'total_fee' => $this->total_fee ?? null, 'locale' => $this->locale ?? null, - 'reserved_until' => $this->reserved_until ?? null, ]; } @@ -259,6 +259,17 @@ public function getRefundStatus(): ?string return $this->refund_status; } + public function setReservedUntil(?string $reserved_until): self + { + $this->reserved_until = $reserved_until; + return $this; + } + + public function getReservedUntil(): ?string + { + return $this->reserved_until; + } + public function setIsManuallyCreated(bool $is_manually_created): self { $this->is_manually_created = $is_manually_created; @@ -412,15 +423,4 @@ public function getLocale(): string { return $this->locale; } - - public function setReservedUntil(?string $reserved_until): self - { - $this->reserved_until = $reserved_until; - return $this; - } - - public function getReservedUntil(): ?string - { - return $this->reserved_until; - } } diff --git a/backend/app/DomainObjects/Generated/TicketCheckInListDomainObjectAbstract.php b/backend/app/DomainObjects/Generated/TicketCheckInListDomainObjectAbstract.php new file mode 100644 index 00000000..6b4722ad --- /dev/null +++ b/backend/app/DomainObjects/Generated/TicketCheckInListDomainObjectAbstract.php @@ -0,0 +1,76 @@ + $this->id ?? null, + 'ticket_id' => $this->ticket_id ?? null, + 'check_in_list_id' => $this->check_in_list_id ?? null, + 'deleted_at' => $this->deleted_at ?? null, + ]; + } + + public function setId(int $id): self + { + $this->id = $id; + return $this; + } + + public function getId(): int + { + return $this->id; + } + + public function setTicketId(int $ticket_id): self + { + $this->ticket_id = $ticket_id; + return $this; + } + + public function getTicketId(): int + { + return $this->ticket_id; + } + + public function setCheckInListId(int $check_in_list_id): self + { + $this->check_in_list_id = $check_in_list_id; + return $this; + } + + public function getCheckInListId(): int + { + return $this->check_in_list_id; + } + + public function setDeletedAt(?string $deleted_at): self + { + $this->deleted_at = $deleted_at; + return $this; + } + + public function getDeletedAt(): ?string + { + return $this->deleted_at; + } +} diff --git a/backend/app/DomainObjects/Generated/TicketsCheckInListDomainObjectAbstract.php b/backend/app/DomainObjects/Generated/TicketsCheckInListDomainObjectAbstract.php new file mode 100644 index 00000000..da5209ea --- /dev/null +++ b/backend/app/DomainObjects/Generated/TicketsCheckInListDomainObjectAbstract.php @@ -0,0 +1,76 @@ + $this->id ?? null, + 'ticket_id' => $this->ticket_id ?? null, + 'check_in_list_id' => $this->check_in_list_id ?? null, + 'deleted_at' => $this->deleted_at ?? null, + ]; + } + + public function setId(int $id): self + { + $this->id = $id; + return $this; + } + + public function getId(): int + { + return $this->id; + } + + public function setTicketId(int $ticket_id): self + { + $this->ticket_id = $ticket_id; + return $this; + } + + public function getTicketId(): int + { + return $this->ticket_id; + } + + public function setCheckInListId(int $check_in_list_id): self + { + $this->check_in_list_id = $check_in_list_id; + return $this; + } + + public function getCheckInListId(): int + { + return $this->check_in_list_id; + } + + public function setDeletedAt(?string $deleted_at): self + { + $this->deleted_at = $deleted_at; + return $this; + } + + public function getDeletedAt(): ?string + { + return $this->deleted_at; + } +} diff --git a/backend/app/DomainObjects/TicketCheckInListDomainObject.php b/backend/app/DomainObjects/TicketCheckInListDomainObject.php new file mode 100644 index 00000000..e8328358 --- /dev/null +++ b/backend/app/DomainObjects/TicketCheckInListDomainObject.php @@ -0,0 +1,7 @@ +setTimezone($userTimezone) ->toString(); } + + public static function utcDateIsPast(string $eventDate): bool + { + return Carbon::parse($eventDate, 'UTC')->isPast(); + } + + public static function utcDateIsFuture(string $eventDate): bool + { + return Carbon::parse($eventDate, 'UTC')->isFuture(); + } } diff --git a/backend/app/Helper/IdHelper.php b/backend/app/Helper/IdHelper.php index 9b579374..db2b23e1 100644 --- a/backend/app/Helper/IdHelper.php +++ b/backend/app/Helper/IdHelper.php @@ -11,8 +11,16 @@ class IdHelper public const EVENT_PREFIX = 'e'; public const ACCOUNT_PREFIX = 'acc'; - public static function randomPrefixedId(string $prefix, int $length = 13): string + public const CHECK_IN_LIST_PREFIX = 'cil'; + public const CHECK_IN_PREFIX = 'ci'; + + public static function shortId(string $prefix, int $length = 13): string + { + return sprintf('%s_%s', $prefix, Str::random($length)); + } + + public static function publicId(int $length = 7, string $suffix = ''): string { - return sprintf('%s%s', $prefix, Str::random($length)); + return Str::upper(Str::random($length)) . $suffix; } } diff --git a/backend/app/Http/Actions/BaseAction.php b/backend/app/Http/Actions/BaseAction.php index 0a648701..c6dbabed 100644 --- a/backend/app/Http/Actions/BaseAction.php +++ b/backend/app/Http/Actions/BaseAction.php @@ -16,6 +16,7 @@ use HiEvents\Resources\BaseResource; use HiEvents\Services\Domain\Auth\AuthUserService; use HiEvents\Services\Infrastructure\Authorization\IsAuthorizedService; +use Illuminate\Contracts\Pagination\Paginator; use Illuminate\Foundation\Validation\ValidatesRequests; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; @@ -61,22 +62,27 @@ protected function filterableResourceResponse( /** * @param class-string $resource - * @param Collection|DomainObjectInterface|LengthAwarePaginator|BaseDTO $data + * @param Collection|DomainObjectInterface|LengthAwarePaginator|BaseDTO|Paginator $data * @param int $statusCode * @param array $meta * @param array $headers * @return JsonResponse */ protected function resourceResponse( - string $resource, - Collection|DomainObjectInterface|LengthAwarePaginator|BaseDTO $data, - int $statusCode = ResponseCodes::HTTP_OK, - array $meta = [], - array $headers = [], + string $resource, + Collection|DomainObjectInterface|LengthAwarePaginator|BaseDTO|Paginator $data, + int $statusCode = ResponseCodes::HTTP_OK, + array $meta = [], + array $headers = [], + array $errors = [], ): JsonResponse { - if ($data instanceof Collection || $data instanceof LengthAwarePaginator) { - $response = ($resource::collection($data)->additional(['meta' => $meta])) + if ($data instanceof Collection || $data instanceof Paginator) { + $additional = array_filter([ + 'meta' => $meta ?? null, + 'errors' => $errors ?? null, + ]); + $response = ($resource::collection($data)->additional($additional)) ->response() ->setStatusCode($statusCode); } else { diff --git a/backend/app/Http/Actions/CheckInLists/CreateCheckInListAction.php b/backend/app/Http/Actions/CheckInLists/CreateCheckInListAction.php new file mode 100644 index 00000000..c10ada72 --- /dev/null +++ b/backend/app/Http/Actions/CheckInLists/CreateCheckInListAction.php @@ -0,0 +1,47 @@ +checkInListHandler->handle( + new UpsertCheckInListDTO( + name: $request->validated('name'), + description: $request->validated('description'), + eventId: $eventId, + ticketIds: $request->validated('ticket_ids'), + expiresAt: $request->validated('expires_at'), + activatesAt: $request->validated('activates_at'), + ) + ); + } catch (UnrecognizedTicketIdException $exception) { + return $this->errorResponse( + message: $exception->getMessage(), + statusCode: Response::HTTP_UNPROCESSABLE_ENTITY, + ); + } + + return $this->resourceResponse( + resource: CheckInListResource::class, + data: $checkInList + ); + } +} diff --git a/backend/app/Http/Actions/CheckInLists/DeleteCheckInListAction.php b/backend/app/Http/Actions/CheckInLists/DeleteCheckInListAction.php new file mode 100644 index 00000000..6341109e --- /dev/null +++ b/backend/app/Http/Actions/CheckInLists/DeleteCheckInListAction.php @@ -0,0 +1,29 @@ +isActionAuthorized($eventId, EventDomainObject::class); + + $this->deleteCheckInListHandler->handle( + eventId: $eventId, + checkInListId: $checkInListId, + ); + + return $this->noContentResponse(); + } +} diff --git a/backend/app/Http/Actions/CheckInLists/EditCheckInListAction.php b/backend/app/Http/Actions/CheckInLists/EditCheckInListAction.php new file mode 100644 index 00000000..db25f3dd --- /dev/null +++ b/backend/app/Http/Actions/CheckInLists/EditCheckInListAction.php @@ -0,0 +1,10 @@ +isActionAuthorized($eventId, EventDomainObject::class); + + $checkInList = $this->getCheckInListHandler->handle( + checkInListId: $checkInListId, + eventId: $eventId, + ); + + return $this->resourceResponse( + resource: CheckInListResource::class, + data: $checkInList, + ); + } +} diff --git a/backend/app/Http/Actions/CheckInLists/GetCheckInListsAction.php b/backend/app/Http/Actions/CheckInLists/GetCheckInListsAction.php new file mode 100644 index 00000000..137f22e5 --- /dev/null +++ b/backend/app/Http/Actions/CheckInLists/GetCheckInListsAction.php @@ -0,0 +1,37 @@ +isActionAuthorized($eventId, EventDomainObject::class); + + return $this->filterableResourceResponse( + resource: CheckInListResource::class, + data: $this->getCheckInListsHandler->handle( + GetCheckInListsDTO::fromArray([ + 'eventId' => $eventId, + 'queryParams' => $this->getPaginationQueryParams($request), + ]), + ), + domainObject: CheckInListDomainObject::class, + ); + } +} diff --git a/backend/app/Http/Actions/CheckInLists/Public/CreateAttendeeCheckInPublicAction.php b/backend/app/Http/Actions/CheckInLists/Public/CreateAttendeeCheckInPublicAction.php new file mode 100644 index 00000000..6cc0d4ee --- /dev/null +++ b/backend/app/Http/Actions/CheckInLists/Public/CreateAttendeeCheckInPublicAction.php @@ -0,0 +1,47 @@ +createAttendeeCheckInPublicHandler->handle(new CreateAttendeeCheckInPublicDTO( + checkInListUuid: $checkInListUuid, + checkInUserIpAddress: $request->ip(), + attendeePublicIds: $request->validated('attendee_public_ids'), + )); + } catch (CannotCheckInException $e) { + return $this->errorResponse( + message: $e->getMessage(), + statusCode: Response::HTTP_CONFLICT, + ); + } + + return $this->resourceResponse( + resource: AttendeeCheckInPublicResource::class, + data: $checkIns->attendeeCheckIns, + errors: $checkIns->errors->toArray() + ); + } +} diff --git a/backend/app/Http/Actions/CheckInLists/Public/DeleteAttendeeCheckInPublicAction.php b/backend/app/Http/Actions/CheckInLists/Public/DeleteAttendeeCheckInPublicAction.php new file mode 100644 index 00000000..6062e043 --- /dev/null +++ b/backend/app/Http/Actions/CheckInLists/Public/DeleteAttendeeCheckInPublicAction.php @@ -0,0 +1,42 @@ +deleteAttendeeCheckInPublicHandler->handle(new DeleteAttendeeCheckInPublicDTO( + checkInListShortId: $checkInListShortId, + checkInShortId: $checkInShortId, + checkInUserIpAddress: $request->ip(), + )); + } catch (CannotCheckInException $e) { + return $this->errorResponse( + message: $e->getMessage(), + statusCode: Response::HTTP_CONFLICT + ); + } + + return $this->deletedResponse(); + } +} diff --git a/backend/app/Http/Actions/CheckInLists/Public/GetCheckInListAttendeesPublicAction.php b/backend/app/Http/Actions/CheckInLists/Public/GetCheckInListAttendeesPublicAction.php new file mode 100644 index 00000000..6cfb47c7 --- /dev/null +++ b/backend/app/Http/Actions/CheckInLists/Public/GetCheckInListAttendeesPublicAction.php @@ -0,0 +1,32 @@ +getCheckInListAttendeesPublicHandler->handle( + shortId: $uuid, + queryParams: QueryParamsDTO::fromArray($request->query->all()) + ); + + return $this->resourceResponse( + resource: AttendeeWithCheckInPublicResource::class, + data: $attendees, + ); + } +} diff --git a/backend/app/Http/Actions/CheckInLists/Public/GetCheckInListPublicAction.php b/backend/app/Http/Actions/CheckInLists/Public/GetCheckInListPublicAction.php new file mode 100644 index 00000000..0b711f9d --- /dev/null +++ b/backend/app/Http/Actions/CheckInLists/Public/GetCheckInListPublicAction.php @@ -0,0 +1,27 @@ +getCheckInListPublicHandler->handle($checkInListShortId); + + return $this->resourceResponse( + resource: CheckInListResourcePublic::class, + data: $checkInList, + ); + } +} diff --git a/backend/app/Http/Actions/CheckInLists/UpdateCheckInListAction.php b/backend/app/Http/Actions/CheckInLists/UpdateCheckInListAction.php new file mode 100644 index 00000000..b98050e7 --- /dev/null +++ b/backend/app/Http/Actions/CheckInLists/UpdateCheckInListAction.php @@ -0,0 +1,52 @@ +isActionAuthorized($eventId, EventDomainObject::class); + + try { + $checkInList = $this->updateCheckInlistHandler->handle( + new UpsertCheckInListDTO( + name: $request->validated('name'), + description: $request->validated('description'), + eventId: $eventId, + ticketIds: $request->validated('ticket_ids'), + expiresAt: $request->validated('expires_at'), + activatesAt: $request->validated('activates_at'), + id: $checkInListId, + ) + ); + } catch (UnrecognizedTicketIdException $exception) { + return $this->errorResponse( + message: $exception->getMessage(), + statusCode: Response::HTTP_UNPROCESSABLE_ENTITY, + ); + } + + return $this->resourceResponse( + resource: CheckInListResource::class, + data: $checkInList + ); + } +} diff --git a/backend/app/Http/Request/CapacityAssigment/UpsertCapacityAssignmentRequest.php b/backend/app/Http/Request/CapacityAssigment/UpsertCapacityAssignmentRequest.php index 8aaea8b7..0873bfac 100644 --- a/backend/app/Http/Request/CapacityAssigment/UpsertCapacityAssignmentRequest.php +++ b/backend/app/Http/Request/CapacityAssigment/UpsertCapacityAssignmentRequest.php @@ -14,7 +14,7 @@ public function rules(): array return [ 'name' => RulesHelper::REQUIRED_STRING, 'capacity' => ['nullable', 'numeric', 'min:1'], - 'status' => [Rule::in(CapacityAssignmentStatus::valuesArray())], + 'status' => ['required', Rule::in(CapacityAssignmentStatus::valuesArray())], 'ticket_ids' => ['required', 'array'], ]; } diff --git a/backend/app/Http/Request/CheckInList/CreateAttendeeCheckInPublicRequest.php b/backend/app/Http/Request/CheckInList/CreateAttendeeCheckInPublicRequest.php new file mode 100644 index 00000000..a95e73ea --- /dev/null +++ b/backend/app/Http/Request/CheckInList/CreateAttendeeCheckInPublicRequest.php @@ -0,0 +1,16 @@ + ['required', 'array'], + 'attendee_public_ids.*' => ['required', 'string'], + ]; + } +} diff --git a/backend/app/Http/Request/CheckInList/UpsertCheckInListRequest.php b/backend/app/Http/Request/CheckInList/UpsertCheckInListRequest.php new file mode 100644 index 00000000..8405521d --- /dev/null +++ b/backend/app/Http/Request/CheckInList/UpsertCheckInListRequest.php @@ -0,0 +1,40 @@ + RulesHelper::REQUIRED_STRING, + 'description' => ['nullable', 'string', 'max:255'], + 'expires_at' => ['nullable', 'date'], + 'activates_at' => ['nullable', 'date'], + 'ticket_ids' => ['required', 'array', 'min:1'], + ]; + } + + public function withValidator($validator): void + { + $validator->sometimes('expires_at', 'after:activates_at', function ($input) { + return $input->activates_at !== null && $input->expires_at !== null; + }); + + $validator->sometimes('activates_at', 'before:expires_at', function ($input) { + return $input->activates_at !== null && $input->expires_at !== null; + }); + } + + public function messages(): array + { + return [ + 'ticket_ids.required' => __('Please select at least one ticket.'), + 'expires_at.after' => __('The expiration date must be after the activation date.'), + 'activates_at.before' => __('The activation date must be before the expiration date.'), + ]; + } +} diff --git a/backend/app/Models/Attendee.php b/backend/app/Models/Attendee.php index 4b2bafe8..9164ce23 100644 --- a/backend/app/Models/Attendee.php +++ b/backend/app/Models/Attendee.php @@ -6,6 +6,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; +use Illuminate\Database\Eloquent\Relations\HasOne; class Attendee extends BaseModel { @@ -33,4 +34,9 @@ public function ticket(): BelongsTo { return $this->belongsTo(Ticket::class); } + + public function check_in(): HasOne + { + return $this->hasOne(AttendeeCheckIn::class); + } } diff --git a/backend/app/Models/AttendeeCheckIn.php b/backend/app/Models/AttendeeCheckIn.php new file mode 100644 index 00000000..be8f6f68 --- /dev/null +++ b/backend/app/Models/AttendeeCheckIn.php @@ -0,0 +1,35 @@ +belongsTo( + related: Ticket::class, + ); + } + + public function checkInList(): BelongsTo + { + return $this->belongsTo(CheckInList::class); + } + + public function attendee(): BelongsTo + { + return $this->belongsTo(Attendee::class); + } +} diff --git a/backend/app/Models/CheckInList.php b/backend/app/Models/CheckInList.php new file mode 100644 index 00000000..d61c62b9 --- /dev/null +++ b/backend/app/Models/CheckInList.php @@ -0,0 +1,32 @@ +belongsToMany( + related: Ticket::class, + table: 'ticket_check_in_lists', + ); + } + + public function event(): BelongsTo + { + return $this->belongsTo(Event::class); + } +} diff --git a/backend/app/Models/Ticket.php b/backend/app/Models/Ticket.php index 1d406188..397c16fc 100644 --- a/backend/app/Models/Ticket.php +++ b/backend/app/Models/Ticket.php @@ -42,4 +42,9 @@ public function capacity_assignments(): BelongsToMany { return $this->belongsToMany(CapacityAssignment::class, 'ticket_capacity_assignments'); } + + public function check_in_lists(): BelongsToMany + { + return $this->belongsToMany(CheckInList::class, 'ticket_check_in_lists'); + } } diff --git a/backend/app/Providers/RepositoryServiceProvider.php b/backend/app/Providers/RepositoryServiceProvider.php index 4c936652..2ae3adc9 100644 --- a/backend/app/Providers/RepositoryServiceProvider.php +++ b/backend/app/Providers/RepositoryServiceProvider.php @@ -6,8 +6,10 @@ use HiEvents\Repository\Eloquent\AccountRepository; use HiEvents\Repository\Eloquent\AccountUserRepository; +use HiEvents\Repository\Eloquent\AttendeeCheckInRepository; use HiEvents\Repository\Eloquent\AttendeeRepository; use HiEvents\Repository\Eloquent\CapacityAssignmentRepository; +use HiEvents\Repository\Eloquent\CheckInListRepository; use HiEvents\Repository\Eloquent\EventDailyStatisticRepository; use HiEvents\Repository\Eloquent\EventRepository; use HiEvents\Repository\Eloquent\EventSettingsRepository; @@ -30,8 +32,10 @@ use HiEvents\Repository\Eloquent\UserRepository; use HiEvents\Repository\Interfaces\AccountRepositoryInterface; use HiEvents\Repository\Interfaces\AccountUserRepositoryInterface; +use HiEvents\Repository\Interfaces\AttendeeCheckInRepositoryInterface; use HiEvents\Repository\Interfaces\AttendeeRepositoryInterface; use HiEvents\Repository\Interfaces\CapacityAssignmentRepositoryInterface; +use HiEvents\Repository\Interfaces\CheckInListRepositoryInterface; use HiEvents\Repository\Interfaces\EventDailyStatisticRepositoryInterface; use HiEvents\Repository\Interfaces\EventRepositoryInterface; use HiEvents\Repository\Interfaces\EventSettingsRepositoryInterface; @@ -84,6 +88,8 @@ class RepositoryServiceProvider extends ServiceProvider AccountUserRepositoryInterface::class => AccountUserRepository::class, CapacityAssignmentRepositoryInterface::class => CapacityAssignmentRepository::class, StripeCustomerRepositoryInterface::class => StripeCustomerRepository::class, + CheckInListRepositoryInterface::class => CheckInListRepository::class, + AttendeeCheckInRepositoryInterface::class => AttendeeCheckInRepository::class, ]; public function register(): void diff --git a/backend/app/Repository/DTO/CheckedInAttendeesCountDTO.php b/backend/app/Repository/DTO/CheckedInAttendeesCountDTO.php new file mode 100644 index 00000000..d104859d --- /dev/null +++ b/backend/app/Repository/DTO/CheckedInAttendeesCountDTO.php @@ -0,0 +1,16 @@ +model->select('attendees.*'); - $this->model->join('orders', 'orders.id', '=', 'attendees.order_id'); - $this->model->whereIn('orders.status', [OrderStatus::COMPLETED->name, OrderStatus::CANCELLED->name]); - - $this->model = $this->model->orderBy( - 'attendees.' . ($params->sort_by ?? AttendeeDomainObject::getDefaultSort()), - $params->sort_direction ?? 'desc', - ); + $this->model = $this->model->select('attendees.*') + ->join('orders', 'orders.id', '=', 'attendees.order_id') + ->whereIn('orders.status', [OrderStatus::COMPLETED->name, OrderStatus::CANCELLED->name]) + ->orderBy( + 'attendees.' . ($params->sort_by ?? AttendeeDomainObject::getDefaultSort()), + $params->sort_direction ?? 'desc', + ); return $this->paginateWhere( where: $where, @@ -89,4 +91,40 @@ public function findByEventId(int $eventId, QueryParamsDTO $params): LengthAware page: $params->page, ); } + + public function getAttendeesByCheckInShortId(string $shortId, QueryParamsDTO $params): Paginator + { + $where = []; + if ($params->query) { + $where[] = static function (Builder $builder) use ($params) { + $builder + ->where( + DB::raw( + sprintf( + "(%s||' '||%s)", + 'attendees.' . AttendeeDomainObjectAbstract::FIRST_NAME, + 'attendees.' . AttendeeDomainObjectAbstract::LAST_NAME, + ) + ), 'ilike', '%' . $params->query . '%') + ->orWhere('attendees.' . AttendeeDomainObjectAbstract::LAST_NAME, 'ilike', '%' . $params->query . '%') + ->orWhere('attendees.' . AttendeeDomainObjectAbstract::FIRST_NAME, 'ilike', '%' . $params->query . '%') + ->orWhere('attendees.' . AttendeeDomainObjectAbstract::PUBLIC_ID, 'ilike', '%' . $params->query . '%') + ->orWhere('attendees.' . AttendeeDomainObjectAbstract::EMAIL, 'ilike', '%' . $params->query . '%'); + }; + } + + $this->model = $this->model->select('attendees.*') + ->join('orders', 'orders.id', '=', 'attendees.order_id') + ->join('ticket_check_in_lists', 'ticket_check_in_lists.ticket_id', '=', 'attendees.ticket_id') + ->join('check_in_lists', 'check_in_lists.id', '=', 'ticket_check_in_lists.check_in_list_id') + ->where('check_in_lists.short_id', $shortId) + ->whereIn('orders.status', [OrderStatus::COMPLETED->name]); + + $this->loadRelation(new Relationship(AttendeeCheckInDomainObject::class, name: 'check_in')); + + return $this->simplePaginateWhere( + where: $where, + limit: 100, + ); + } } diff --git a/backend/app/Repository/Eloquent/BaseRepository.php b/backend/app/Repository/Eloquent/BaseRepository.php index da3ce5c3..b0cde250 100644 --- a/backend/app/Repository/Eloquent/BaseRepository.php +++ b/backend/app/Repository/Eloquent/BaseRepository.php @@ -6,6 +6,7 @@ use BadMethodCallException; use Carbon\Carbon; +use Illuminate\Contracts\Pagination\Paginator; use Illuminate\Database\DatabaseManager; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Model; @@ -82,6 +83,19 @@ public function paginateWhere( return $this->handleResults($results); } + public function simplePaginateWhere( + array $where, + int $limit = null, + array $columns = self::DEFAULT_COLUMNS, + ): Paginator + { + $this->applyConditions($where); + $results = $this->model->simplePaginate($this->getPaginationPerPage($limit), $columns); + $this->resetModel(); + + return $this->handleResults($results); + } + public function paginateEloquentRelation( Relation $relation, int $limit = null, @@ -300,6 +314,10 @@ protected function handleResults($results, string $domainObjectOverride = null) return $results->setCollection(collect($domainObjects)); } + if ($results instanceof Paginator) { + return $results->setCollection(collect($domainObjects)); + } + return collect($domainObjects); } diff --git a/backend/app/Repository/Eloquent/CheckInListRepository.php b/backend/app/Repository/Eloquent/CheckInListRepository.php new file mode 100644 index 00000000..a2a82928 --- /dev/null +++ b/backend/app/Repository/Eloquent/CheckInListRepository.php @@ -0,0 +1,130 @@ +db->selectOne($sql, ['check_in_list_id' => $checkInListId]); + + return new CheckedInAttendeesCountDTO( + checkInListId: $checkInListId, + checkedInCount: $query->checked_in_attendees ?? 0, + totalAttendeesCount: $query->total_attendees ?? 0, + ); + } + + public function getCheckedInAttendeeCountByIds(array $checkInListIds): Collection + { + $placeholders = implode(',', array_fill(0, count($checkInListIds), '?')); + + $sql = <<db->select($sql, $checkInListIds); + + return collect($query)->map( + static fn($item) => new CheckedInAttendeesCountDTO( + checkInListId: $item->check_in_list_id, + checkedInCount: $item->checked_in_attendees, + totalAttendeesCount: $item->total_attendees, + ) + ); + } + + public function findByEventId(int $eventId, QueryParamsDTO $params): LengthAwarePaginator + { + $where = [ + [CheckInListDomainObjectAbstract::EVENT_ID, '=', $eventId] + ]; + + if (!empty($params->query)) { + $where[] = static function (Builder $builder) use ($params) { + $builder + ->where(CapacityAssignmentDomainObjectAbstract::NAME, 'ilike', '%' . $params->query . '%'); + }; + } + + $this->model = $this->model->orderBy( + $params->sort_by ?? CheckInListDomainObject::getDefaultSort(), + $params->sort_direction ?? CheckInListDomainObject::getDefaultSortDirection(), + ); + + return $this->paginateWhere( + where: $where, + limit: $params->per_page, + page: $params->page, + ); + } +} diff --git a/backend/app/Repository/Eloquent/TicketRepository.php b/backend/app/Repository/Eloquent/TicketRepository.php index e6cc84a9..f06604c0 100644 --- a/backend/app/Repository/Eloquent/TicketRepository.php +++ b/backend/app/Repository/Eloquent/TicketRepository.php @@ -11,12 +11,12 @@ use HiEvents\DomainObjects\TicketDomainObject; use HiEvents\Http\DTO\QueryParamsDTO; use HiEvents\Models\CapacityAssignment; +use HiEvents\Models\CheckInList; use HiEvents\Models\Ticket; use HiEvents\Repository\Interfaces\TicketRepositoryInterface; use Illuminate\Database\Eloquent\Builder; use Illuminate\Pagination\LengthAwarePaginator; use Illuminate\Support\Collection; -use Illuminate\Support\Facades\DB; use RuntimeException; class TicketRepository extends BaseRepository implements TicketRepositoryInterface @@ -126,7 +126,7 @@ public function getTicketsByTaxId(int $taxId): Collection public function getCapacityAssignmentsByTicketId(int $ticketId): Collection { - $capacityAssignments = CapacityAssignment::whereHas('tickets', static function($query) use ($ticketId) { + $capacityAssignments = CapacityAssignment::whereHas('tickets', static function ($query) use ($ticketId) { $query->where('ticket_id', $ticketId); })->get(); @@ -140,9 +140,45 @@ public function addTaxesAndFeesToTicket(int $ticketId, array $taxIds): void public function addCapacityAssignmentToTickets(int $capacityAssignmentId, array $ticketIds): void { - Ticket::whereIn('id', $ticketIds)->each(function (Ticket $ticket) use ($capacityAssignmentId) { - $ticket->capacity_assignments()->syncWithoutDetaching([$capacityAssignmentId]); - }); + $ticketIds = array_unique($ticketIds); + + Ticket::whereNotIn('id', $ticketIds) + ->whereHas('capacity_assignments', function ($query) use ($capacityAssignmentId) { + $query->where('capacity_assignment_id', $capacityAssignmentId); + }) + ->each(function (Ticket $ticket) use ($capacityAssignmentId) { + $ticket->capacity_assignments()->detach($capacityAssignmentId); + }); + + Ticket::whereIn('id', $ticketIds) + ->each(function (Ticket $ticket) use ($capacityAssignmentId) { + $ticket->capacity_assignments()->syncWithoutDetaching([$capacityAssignmentId]); + }); + } + + public function addCheckInListToTickets(int $checkInListId, array $ticketIds): void + { + $ticketIds = array_unique($ticketIds); + + Ticket::whereNotIn('id', $ticketIds) + ->whereHas('check_in_lists', function ($query) use ($checkInListId) { + $query->where('check_in_list_id', $checkInListId); + }) + ->each(function (Ticket $ticket) use ($checkInListId) { + $ticket->check_in_lists()->detach($checkInListId); + }); + + Ticket::whereIn('id', $ticketIds) + ->each(function (Ticket $ticket) use ($checkInListId) { + $ticket->check_in_lists()->syncWithoutDetaching([$checkInListId]); + }); + } + + public function removeCheckInListFromTickets(int $checkInListId): void + { + $checkInList = CheckInList::find($checkInListId); + + $checkInList?->tickets()->detach(); } public function removeCapacityAssignmentFromTickets(int $capacityAssignmentId): void diff --git a/backend/app/Repository/Interfaces/AttendeeCheckInRepositoryInterface.php b/backend/app/Repository/Interfaces/AttendeeCheckInRepositoryInterface.php new file mode 100644 index 00000000..ac0b5bfc --- /dev/null +++ b/backend/app/Repository/Interfaces/AttendeeCheckInRepositoryInterface.php @@ -0,0 +1,14 @@ + + */ +interface AttendeeCheckInRepositoryInterface extends RepositoryInterface +{ + +} diff --git a/backend/app/Repository/Interfaces/AttendeeRepositoryInterface.php b/backend/app/Repository/Interfaces/AttendeeRepositoryInterface.php index c0b6a95e..fba38d34 100644 --- a/backend/app/Repository/Interfaces/AttendeeRepositoryInterface.php +++ b/backend/app/Repository/Interfaces/AttendeeRepositoryInterface.php @@ -2,12 +2,12 @@ namespace HiEvents\Repository\Interfaces; -use Illuminate\Pagination\LengthAwarePaginator; use HiEvents\DomainObjects\AttendeeDomainObject; use HiEvents\Http\DTO\QueryParamsDTO; use HiEvents\Repository\Eloquent\BaseRepository; +use Illuminate\Contracts\Pagination\Paginator; +use Illuminate\Pagination\LengthAwarePaginator; use Illuminate\Support\Collection; -use HiEvents\Repository\Interfaces\RepositoryInterface; /** * @extends BaseRepository @@ -17,4 +17,6 @@ interface AttendeeRepositoryInterface extends RepositoryInterFace public function findByEventId(int $eventId, QueryParamsDTO $params): LengthAwarePaginator; public function findByEventIdForExport(int $eventId): Collection; + + public function getAttendeesByCheckInShortId(string $shortId, QueryParamsDTO $params): Paginator; } diff --git a/backend/app/Repository/Interfaces/CheckInListRepositoryInterface.php b/backend/app/Repository/Interfaces/CheckInListRepositoryInterface.php new file mode 100644 index 00000000..787e734a --- /dev/null +++ b/backend/app/Repository/Interfaces/CheckInListRepositoryInterface.php @@ -0,0 +1,26 @@ + + */ +interface CheckInListRepositoryInterface extends RepositoryInterface +{ + public function findByEventId(int $eventId, QueryParamsDTO $params): LengthAwarePaginator; + + public function getCheckedInAttendeeCountById(int $checkInListId): CheckedInAttendeesCountDTO; + + /** + * @param array $checkInListIds + * + * @return Collection + */ + public function getCheckedInAttendeeCountByIds(array $checkInListIds): Collection; +} diff --git a/backend/app/Repository/Interfaces/RepositoryInterface.php b/backend/app/Repository/Interfaces/RepositoryInterface.php index 45a6ea57..0baa2bcc 100644 --- a/backend/app/Repository/Interfaces/RepositoryInterface.php +++ b/backend/app/Repository/Interfaces/RepositoryInterface.php @@ -3,6 +3,7 @@ namespace HiEvents\Repository\Interfaces; use Exception; +use Illuminate\Contracts\Pagination\Paginator; use Illuminate\Database\Eloquent\Relations\Relation; use Illuminate\Pagination\LengthAwarePaginator; use Illuminate\Support\Collection; @@ -57,6 +58,18 @@ public function paginateWhere( array $columns = self::DEFAULT_COLUMNS ): LengthAwarePaginator; + /** + * @param array $where + * @param int|null $limit + * @param array $columns + * @return LengthAwarePaginator + */ + public function simplePaginateWhere( + array $where, + int $limit = null, + array $columns = self::DEFAULT_COLUMNS, + ): Paginator; + /** * @param Relation $relation * @param int $limit diff --git a/backend/app/Repository/Interfaces/TicketRepositoryInterface.php b/backend/app/Repository/Interfaces/TicketRepositoryInterface.php index 3b4501c3..4143956a 100644 --- a/backend/app/Repository/Interfaces/TicketRepositoryInterface.php +++ b/backend/app/Repository/Interfaces/TicketRepositoryInterface.php @@ -4,11 +4,11 @@ namespace HiEvents\Repository\Interfaces; -use Illuminate\Pagination\LengthAwarePaginator; -use Illuminate\Support\Collection; use HiEvents\DomainObjects\TicketDomainObject; use HiEvents\Http\DTO\QueryParamsDTO; use HiEvents\Repository\Eloquent\BaseRepository; +use Illuminate\Pagination\LengthAwarePaginator; +use Illuminate\Support\Collection; /** * @extends BaseRepository @@ -61,6 +61,19 @@ public function addTaxesAndFeesToTicket(int $ticketId, array $taxIds): void; */ public function addCapacityAssignmentToTickets(int $capacityAssignmentId, array $ticketIds): void; + /** + * @param int $checkInListId + * @param array $ticketIds + * @return void + */ + public function addCheckInListToTickets(int $checkInListId, array $ticketIds): void; + + /** + * @param int $checkInListId + * @return void + */ + public function removeCheckInListFromTickets(int $checkInListId): void; + /** * @param int $capacityAssignmentId * @return void diff --git a/backend/app/Resources/Account/AccountResource.php b/backend/app/Resources/Account/AccountResource.php index cbffa058..16899df9 100644 --- a/backend/app/Resources/Account/AccountResource.php +++ b/backend/app/Resources/Account/AccountResource.php @@ -21,6 +21,8 @@ public function toArray(Request $request): array 'updated_at' => $this->getUpdatedAt(), 'stripe_connect_setup_complete' => $this->getStripeConnectSetupComplete(), 'is_account_email_confirmed' => $this->getAccountVerifiedAt() !== null, + // this really should not be on the account level + 'is_saas_mode_enabled' => config('app.saas_mode_enabled'), ]; } } diff --git a/backend/app/Resources/Attendee/AttendeeResource.php b/backend/app/Resources/Attendee/AttendeeResource.php index c6694f5c..77a7f924 100644 --- a/backend/app/Resources/Attendee/AttendeeResource.php +++ b/backend/app/Resources/Attendee/AttendeeResource.php @@ -28,8 +28,6 @@ public function toArray(Request $request): array 'first_name' => $this->getFirstName(), 'last_name' => $this->getLastName(), 'public_id' => $this->getPublicId(), - 'checked_in_at' => $this->getCheckedInAt(), - 'checked_in_by' => $this->getCheckedInBy(), 'short_id' => $this->getShortId(), 'locale' => $this->getLocale(), 'ticket' => $this->when( diff --git a/backend/app/Resources/Attendee/AttendeeResourcePublic.php b/backend/app/Resources/Attendee/AttendeeResourcePublic.php index fa2e6ef2..8c40ffcd 100644 --- a/backend/app/Resources/Attendee/AttendeeResourcePublic.php +++ b/backend/app/Resources/Attendee/AttendeeResourcePublic.php @@ -21,7 +21,6 @@ public function toArray(Request $request): array 'first_name' => $this->getFirstName(), 'last_name' => $this->getLastName(), 'public_id' => $this->getPublicId(), - 'checked_in_at' => $this->getCheckedInAt(), 'short_id' => $this->getShortId(), 'ticket_id' => $this->getTicketId(), 'ticket_price_id' => $this->getTicketPriceId(), diff --git a/backend/app/Resources/Attendee/AttendeeWithCheckInPublicResource.php b/backend/app/Resources/Attendee/AttendeeWithCheckInPublicResource.php new file mode 100644 index 00000000..9750f8c1 --- /dev/null +++ b/backend/app/Resources/Attendee/AttendeeWithCheckInPublicResource.php @@ -0,0 +1,31 @@ + $this->getId(), + 'email' => $this->getEmail(), + 'first_name' => $this->getFirstName(), + 'last_name' => $this->getLastName(), + 'public_id' => $this->getPublicId(), + 'ticket_id' => $this->getTicketId(), + 'ticket_price_id' => $this->getTicketPriceId(), + 'locale' => $this->getLocale(), + $this->mergeWhen($this->getCheckIn() !== null, [ + 'check_in' => new AttendeeCheckInPublicResource($this->getCheckIn()), + ]), + ]; + } +} diff --git a/backend/app/Resources/CheckInList/AttendeeCheckInPublicResource.php b/backend/app/Resources/CheckInList/AttendeeCheckInPublicResource.php new file mode 100644 index 00000000..d12b3013 --- /dev/null +++ b/backend/app/Resources/CheckInList/AttendeeCheckInPublicResource.php @@ -0,0 +1,23 @@ + $this->getId(), + 'short_id' => $this->getShortId(), + 'check_in_list_id' => $this->getCheckInListId(), + 'attendee_id' => $this->getAttendeeId(), + 'checked_in_at' => $this->getCreatedAt(), + ]; + } +} diff --git a/backend/app/Resources/CheckInList/CheckInListResource.php b/backend/app/Resources/CheckInList/CheckInListResource.php new file mode 100644 index 00000000..51d96902 --- /dev/null +++ b/backend/app/Resources/CheckInList/CheckInListResource.php @@ -0,0 +1,34 @@ + $this->getId(), + 'name' => $this->getName(), + 'description' => $this->getDescription(), + 'expires_at' => $this->getExpiresAt(), + 'activates_at' => $this->getActivatesAt(), + 'short_id' => $this->getShortId(), + 'total_attendees' => $this->getTotalAttendeesCount(), + 'checked_in_attendees' => $this->getCheckedInCount(), + $this->mergeWhen($this->getEvent() !== null, fn() => [ + 'is_expired' => $this->isExpired($this->getEvent()->getTimezone()), + 'is_active' => $this->isActivated($this->getEvent()->getTimezone()), + ]), + $this->mergeWhen($this->getTickets() !== null, fn() => [ + 'tickets' => TicketResource::collection($this->getTickets()), + ]), + ]; + } +} diff --git a/backend/app/Resources/CheckInList/CheckInListResourcePublic.php b/backend/app/Resources/CheckInList/CheckInListResourcePublic.php new file mode 100644 index 00000000..04668700 --- /dev/null +++ b/backend/app/Resources/CheckInList/CheckInListResourcePublic.php @@ -0,0 +1,36 @@ + $this->getId(), + 'short_id' => $this->getShortId(), + 'name' => $this->getName(), + 'description' => $this->getDescription(), + 'expires_at' => $this->getExpiresAt(), + 'activates_at' => $this->getActivatesAt(), + 'total_attendees' => $this->getTotalAttendeesCount(), + 'checked_in_attendees' => $this->getCheckedInCount(), + $this->mergeWhen($this->getEvent() !== null, fn() => [ + 'is_expired' => $this->isExpired($this->getEvent()->getTimezone()), + 'is_active' => $this->isActivated($this->getEvent()->getTimezone()), + 'event' => EventResourcePublic::make($this->getEvent()), + ]), + $this->mergeWhen($this->getTickets() !== null, fn() => [ + 'tickets' => TicketMinimalResourcePublic::collection($this->getTickets()), + ]), + ]; + } +} diff --git a/backend/app/Services/Domain/CapacityAssignment/UpdateCapacityAssignmentService.php b/backend/app/Services/Domain/CapacityAssignment/UpdateCapacityAssignmentService.php index b540b732..a0d0899a 100644 --- a/backend/app/Services/Domain/CapacityAssignment/UpdateCapacityAssignmentService.php +++ b/backend/app/Services/Domain/CapacityAssignment/UpdateCapacityAssignmentService.php @@ -64,7 +64,10 @@ private function updateAssignmentAndAssociateTickets( ); } - return $capacityAssignment; + return $this->capacityAssignmentRepository->findFirstWhere([ + CapacityAssignmentDomainObjectAbstract::ID => $capacityAssignment->getId(), + CapacityAssignmentDomainObjectAbstract::EVENT_ID => $capacityAssignment->getEventId(), + ]); }); } } diff --git a/backend/app/Services/Domain/CheckInList/CheckInListDataService.php b/backend/app/Services/Domain/CheckInList/CheckInListDataService.php new file mode 100644 index 00000000..3795ad04 --- /dev/null +++ b/backend/app/Services/Domain/CheckInList/CheckInListDataService.php @@ -0,0 +1,88 @@ +getTickets()->map(fn($ticket) => $ticket->getId())->toArray() ?? []; + + if (!in_array($attendee->getTicketId(), $allowedTicketIds, true)) { + throw new CannotCheckInException( + __('Attendee :attendee_name is not allowed to check in using this check-in list', [ + 'attendee_name' => $attendee->getFullName(), + ]) + ); + } + } + + /** + * @return Collection + * @throws Exception + * + * @throws CannotCheckInException + */ + public function getAttendees(array $attendeePublicIds): Collection + { + $attendeePublicIds = array_unique($attendeePublicIds); + + $attendees = $this->attendeeRepository->findWhereIn( + field: AttendeeDomainObjectAbstract::PUBLIC_ID, + values: $attendeePublicIds + ); + + if (count($attendees) !== count($attendeePublicIds)) { + throw new CannotCheckInException(__('Invalid attendee code detected: :attendees ', [ + 'attendees' => implode(', ', array_diff( + $attendeePublicIds, + $attendees->pluck(AttendeeDomainObjectAbstract::PUBLIC_ID)->toArray()) + ), + ])); + } + + return $attendees; + } + + /** + * @throws CannotCheckInException + */ + public function getCheckInList(string $checkInListUuid): CheckInListDomainObject + { + $checkInList = $this->checkInListRepository + ->loadRelation(TicketDomainObject::class) + ->findFirstWhere([ + CheckInListDomainObjectAbstract::SHORT_ID => $checkInListUuid, + ]); + + if ($checkInList === null) { + throw new CannotCheckInException(__('Check-in list not found')); + } + + return $checkInList; + } +} diff --git a/backend/app/Services/Domain/CheckInList/CheckInListTicketAssociationService.php b/backend/app/Services/Domain/CheckInList/CheckInListTicketAssociationService.php new file mode 100644 index 00000000..24c96c4e --- /dev/null +++ b/backend/app/Services/Domain/CheckInList/CheckInListTicketAssociationService.php @@ -0,0 +1,53 @@ +databaseManager->transaction(function () use ($checkInListId, $ticketIds, $removePreviousAssignments) { + $this->associateTicketsWithCheckInList( + checkInListId: $checkInListId, + ticketIds: $ticketIds, + removePreviousAssignments: $removePreviousAssignments, + ); + }); + } + + private function associateTicketsWithCheckInList( + int $checkInListId, + ?array $ticketIds, + bool $removePreviousAssignments = true + ): void + { + if (empty($ticketIds)) { + return; + } + + if ($removePreviousAssignments) { + $this->ticketRepository->removeCheckInListFromTickets( + checkInListId: $checkInListId, + ); + } + + $this->ticketRepository->addCheckInListToTickets( + checkInListId: $checkInListId, + ticketIds: array_unique($ticketIds), + ); + } +} diff --git a/backend/app/Services/Domain/CheckInList/CreateAttendeeCheckInService.php b/backend/app/Services/Domain/CheckInList/CreateAttendeeCheckInService.php new file mode 100644 index 00000000..8952f041 --- /dev/null +++ b/backend/app/Services/Domain/CheckInList/CreateAttendeeCheckInService.php @@ -0,0 +1,100 @@ +checkInListDataService->getAttendees($attendeePublicIds); + $checkInList = $this->checkInListDataService->getCheckInList($checkInListUuid); + + $this->validateCheckInListIsActive($checkInList); + + $existingCheckIns = $this->attendeeCheckInRepository->findWhereIn( + field: AttendeeCheckInDomainObjectAbstract::ATTENDEE_ID, + values: $attendees->filter( + fn(AttendeeDomainObject $attendee) => in_array($attendee->getPublicId(), $attendeePublicIds, true) + )->map( + fn(AttendeeDomainObject $attendee) => $attendee->getId() + )->toArray(), + ); + + $errors = new ErrorBagDTO(); + $checkIns = new Collection(); + + foreach ($attendees as $attendee) { + $this->checkInListDataService->verifyAttendeeBelongsToCheckInList($checkInList, $attendee); + + $existingCheckIn = $existingCheckIns->first( + fn($checkIn) => $checkIn->getAttendeeId() === $attendee->getId() + ); + + if ($existingCheckIn) { + $checkIns->push($existingCheckIn); + $errors->addError( + key: $attendee->getPublicId(), + message: __('Attendee :attendee_name is already checked in', [ + 'attendee_name' => $attendee->getFullName(), + ]) + ); + continue; + } + + $checkIns->push( + $this->attendeeCheckInRepository->create([ + AttendeeCheckInDomainObjectAbstract::ATTENDEE_ID => $attendee->getId(), + AttendeeCheckInDomainObjectAbstract::CHECK_IN_LIST_ID => $checkInList->getId(), + AttendeeCheckInDomainObjectAbstract::IP_ADDRESS => $checkInUserIpAddress, + AttendeeCheckInDomainObjectAbstract::TICKET_ID => $attendee->getTicketId(), + AttendeeCheckInDomainObjectAbstract::SHORT_ID => IdHelper::shortId(IdHelper::CHECK_IN_PREFIX), + ]) + ); + } + + return new CreateAttendeeCheckInsResponseDTO( + attendeeCheckIns: $checkIns, + errors: $errors, + ); + } + + /** + * @throws CannotCheckInException + */ + private function validateCheckInListIsActive(CheckInListDomainObject $checkInList): void + { + if ($checkInList->getExpiresAt() && DateHelper::utcDateIsPast($checkInList->getExpiresAt())) { + throw new CannotCheckInException(__('Check-in list has expired')); + } + + if ($checkInList->getActivatesAt() && DateHelper::utcDateIsFuture($checkInList->getActivatesAt())) { + throw new CannotCheckInException(__('Check-in list is not active yes')); + } + } +} diff --git a/backend/app/Services/Domain/CheckInList/CreateAttendeeCheckInsResponseDTO.php b/backend/app/Services/Domain/CheckInList/CreateAttendeeCheckInsResponseDTO.php new file mode 100644 index 00000000..f419a289 --- /dev/null +++ b/backend/app/Services/Domain/CheckInList/CreateAttendeeCheckInsResponseDTO.php @@ -0,0 +1,17 @@ +databaseManager->transaction(function () use ($checkInList, $ticketIds) { + $this->eventTicketValidationService->validateTicketIds($ticketIds, $checkInList->getEventId()); + $event = $this->eventRepository->findById($checkInList->getEventId()); + + $newCheckInList = $this->checkInListRepository->create([ + CheckInListDomainObjectAbstract::NAME => $checkInList->getName(), + CheckInListDomainObjectAbstract::DESCRIPTION => $checkInList->getDescription(), + CheckInListDomainObjectAbstract::EVENT_ID => $checkInList->getEventId(), + CheckInListDomainObjectAbstract::EXPIRES_AT => $checkInList->getExpiresAt() + ? DateHelper::convertToUTC($checkInList->getExpiresAt(), $event->getTimezone()) + : null, + CheckInListDomainObjectAbstract::ACTIVATES_AT => $checkInList->getActivatesAt() + ? DateHelper::convertToUTC($checkInList->getActivatesAt(), $event->getTimezone()) + : null, + CheckInListDomainObjectAbstract::SHORT_ID => IdHelper::shortId(IdHelper::CHECK_IN_LIST_PREFIX), + ]); + + $this->checkInListTicketAssociationService->addCheckInListToTickets( + checkInListId: $newCheckInList->getId(), + ticketIds: $ticketIds, + removePreviousAssignments: false, + ); + + return $newCheckInList; + }); + } +} diff --git a/backend/app/Services/Domain/CheckInList/DeleteAttendeeCheckInService.php b/backend/app/Services/Domain/CheckInList/DeleteAttendeeCheckInService.php new file mode 100644 index 00000000..fc5017df --- /dev/null +++ b/backend/app/Services/Domain/CheckInList/DeleteAttendeeCheckInService.php @@ -0,0 +1,48 @@ +attendeeCheckInRepository + ->loadRelation(new Relationship(AttendeeDomainObject::class, name: 'attendee')) + ->findFirstWhere([ + AttendeeCheckInDomainObjectAbstract::SHORT_ID => $checkInShortId, + ]); + + if ($checkIn === null) { + throw new CannotCheckInException(__('This attendee is not checked in')); + } + + $checkInList = $this->checkInListDataService->getCheckInList($checkInListShortId); + + if ($checkInList->getId() !== $checkIn->getCheckInListId()) { + throw new CannotCheckInException(__('Attendee does not belong to this check-in list')); + } + + $this->attendeeCheckInRepository->deleteById($checkIn->getId()); + } +} diff --git a/backend/app/Services/Domain/CheckInList/UpdateCheckInListService.php b/backend/app/Services/Domain/CheckInList/UpdateCheckInListService.php new file mode 100644 index 00000000..b26fd8b6 --- /dev/null +++ b/backend/app/Services/Domain/CheckInList/UpdateCheckInListService.php @@ -0,0 +1,66 @@ +databaseManager->transaction(function () use ($checkInList, $ticketIds) { + $this->eventTicketValidationService->validateTicketIds($ticketIds, $checkInList->getEventId()); + $event = $this->eventRepository->findById($checkInList->getEventId()); + + $this->checkInListRepository->updateWhere( + attributes: [ + CheckInListDomainObjectAbstract::NAME => $checkInList->getName(), + CheckInListDomainObjectAbstract::DESCRIPTION => $checkInList->getDescription(), + CheckInListDomainObjectAbstract::EVENT_ID => $checkInList->getEventId(), + CheckInListDomainObjectAbstract::EXPIRES_AT => $checkInList->getExpiresAt() + ? DateHelper::convertToUTC($checkInList->getExpiresAt(), $event->getTimezone()) + : null, + CheckInListDomainObjectAbstract::ACTIVATES_AT => $checkInList->getActivatesAt() + ? DateHelper::convertToUTC($checkInList->getActivatesAt(), $event->getTimezone()) + : null, + ], + where: [ + CheckInListDomainObjectAbstract::ID => $checkInList->getId(), + CheckInListDomainObjectAbstract::EVENT_ID => $checkInList->getEventId(), + ] + ); + + $this->checkInListTicketAssociationService->addCheckInListToTickets( + checkInListId: $checkInList->getId(), + ticketIds: $ticketIds, + ); + + return $this->checkInListRepository->findFirstWhere( + where: [ + CheckInListDomainObjectAbstract::ID => $checkInList->getId(), + CheckInListDomainObjectAbstract::EVENT_ID => $checkInList->getEventId(), + ] + ); + }); + } +} diff --git a/backend/app/Services/Domain/Event/CreateEventService.php b/backend/app/Services/Domain/Event/CreateEventService.php index 6c743a4a..4d8fdd86 100644 --- a/backend/app/Services/Domain/Event/CreateEventService.php +++ b/backend/app/Services/Domain/Event/CreateEventService.php @@ -95,7 +95,7 @@ private function handleEventCreate(EventDomainObject $eventData): EventDomainObj 'account_id' => $eventData->getAccountId(), 'user_id' => $eventData->getUserId(), 'status' => $eventData->getStatus(), - 'short_id' => IdHelper::randomPrefixedId(IdHelper::EVENT_PREFIX), + 'short_id' => IdHelper::shortId(IdHelper::EVENT_PREFIX), 'attributes' => $eventData->getAttributes(), ]); } diff --git a/backend/app/Services/Domain/Order/OrderManagementService.php b/backend/app/Services/Domain/Order/OrderManagementService.php index acfe5bc2..2d5a0cec 100644 --- a/backend/app/Services/Domain/Order/OrderManagementService.php +++ b/backend/app/Services/Domain/Order/OrderManagementService.php @@ -43,16 +43,15 @@ public function createNewOrder( ): OrderDomainObject { $reservedUntil = Carbon::now()->addMinutes($timeOutMinutes); - $publicId = Str::upper(Str::random(7)); return $this->orderRepository->create([ 'event_id' => $eventId, - 'short_id' => IdHelper::randomPrefixedId(IdHelper::ORDER_PREFIX), + 'short_id' => IdHelper::shortId(IdHelper::ORDER_PREFIX), 'reserved_until' => $reservedUntil->toString(), 'status' => OrderStatus::RESERVED->name, 'session_id' => $sessionId, 'currency' => $event->getCurrency(), - 'public_id' => $publicId, + 'public_id' => IdHelper::publicId(), 'promo_code_id' => $promoCode?->getId(), 'promo_code' => $promoCode?->getCode(), 'locale' => $locale, diff --git a/backend/app/Services/Handlers/Account/CreateAccountHandler.php b/backend/app/Services/Handlers/Account/CreateAccountHandler.php index 5c08cff3..2e7c50cf 100644 --- a/backend/app/Services/Handlers/Account/CreateAccountHandler.php +++ b/backend/app/Services/Handlers/Account/CreateAccountHandler.php @@ -56,7 +56,7 @@ public function handle(CreateAccountDTO $accountData): AccountDomainObject 'currency_code' => $this->getCurrencyCode($accountData), 'name' => $accountData->first_name . ($accountData->last_name ? ' ' . $accountData->last_name : ''), 'email' => strtolower($accountData->email), - 'short_id' => IdHelper::randomPrefixedId(IdHelper::ACCOUNT_PREFIX), + 'short_id' => IdHelper::shortId(IdHelper::ACCOUNT_PREFIX), // If the app is not running in SaaS mode, we can immediately verify the account. // Same goes for the email verification below. 'account_verified_at' => $isSaasMode ? null : now()->toDateTimeString(), diff --git a/backend/app/Services/Handlers/Attendee/CreateAttendeeHandler.php b/backend/app/Services/Handlers/Attendee/CreateAttendeeHandler.php index 2bce36f0..ad9fe329 100644 --- a/backend/app/Services/Handlers/Attendee/CreateAttendeeHandler.php +++ b/backend/app/Services/Handlers/Attendee/CreateAttendeeHandler.php @@ -3,6 +3,7 @@ namespace HiEvents\Services\Handlers\Attendee; use Brick\Money\Money; +use Faker\Generator; use HiEvents\DomainObjects\AttendeeDomainObject; use HiEvents\DomainObjects\Generated\AttendeeDomainObjectAbstract; use HiEvents\DomainObjects\Generated\OrderDomainObjectAbstract; @@ -100,7 +101,6 @@ public function handle(CreateAttendeeDTO $attendeeDTO): AttendeeDomainObject private function createOrder(int $eventId, CreateAttendeeDTO $attendeeDTO): OrderDomainObject { $event = $this->eventRepository->findById($eventId); - $publicId = Str::upper(Str::random(5)); $total = Money::of($attendeeDTO->amount_paid, $event->getCurrency()); return $this->orderRepository->create( @@ -110,13 +110,13 @@ private function createOrder(int $eventId, CreateAttendeeDTO $attendeeDTO): Orde OrderDomainObjectAbstract::LAST_NAME => $attendeeDTO->last_name, OrderDomainObjectAbstract::EMAIL => $attendeeDTO->email, OrderDomainObjectAbstract::EVENT_ID => $eventId, - OrderDomainObjectAbstract::SHORT_ID => IdHelper::randomPrefixedId(IdHelper::ORDER_PREFIX), + OrderDomainObjectAbstract::SHORT_ID => IdHelper::shortId(IdHelper::ORDER_PREFIX), OrderDomainObjectAbstract::STATUS => OrderStatus::COMPLETED->name, OrderDomainObjectAbstract::PAYMENT_STATUS => $total->isZero() ? OrderPaymentStatus::NO_PAYMENT_REQUIRED->name : OrderPaymentStatus::PAYMENT_RECEIVED->name, OrderDomainObjectAbstract::CURRENCY => $event->getCurrency(), - OrderDomainObjectAbstract::PUBLIC_ID => $publicId, + OrderDomainObjectAbstract::PUBLIC_ID => IdHelper::publicId(), OrderDomainObjectAbstract::IS_MANUALLY_CREATED => true, OrderDomainObjectAbstract::LOCALE => $attendeeDTO->locale, ] @@ -219,7 +219,7 @@ private function createAttendee(OrderDomainObject $order, CreateAttendeeDTO $att AttendeeDomainObjectAbstract::LAST_NAME => $attendeeDTO->last_name, AttendeeDomainObjectAbstract::ORDER_ID => $order->getId(), AttendeeDomainObjectAbstract::PUBLIC_ID => $order->getPublicId() . '-1', - AttendeeDomainObjectAbstract::SHORT_ID => IdHelper::randomPrefixedId(IdHelper::ATTENDEE_PREFIX), + AttendeeDomainObjectAbstract::SHORT_ID => IdHelper::shortId(IdHelper::ATTENDEE_PREFIX), AttendeeDomainObjectAbstract::LOCALE => $attendeeDTO->locale, ]); } diff --git a/backend/app/Services/Handlers/CheckInList/CreateCheckInListHandler.php b/backend/app/Services/Handlers/CheckInList/CreateCheckInListHandler.php new file mode 100644 index 00000000..a3d5ed45 --- /dev/null +++ b/backend/app/Services/Handlers/CheckInList/CreateCheckInListHandler.php @@ -0,0 +1,35 @@ +setName($listData->name) + ->setDescription($listData->description) + ->setEventId($listData->eventId) + ->setExpiresAt($listData->expiresAt) + ->setActivatesAt($listData->activatesAt); + + return $this->createCheckInListService->createCheckInList( + checkInList: $checkInList, + ticketIds: $listData->ticketIds + ); + } +} diff --git a/backend/app/Services/Handlers/CheckInList/DTO/GetCheckInListsDTO.php b/backend/app/Services/Handlers/CheckInList/DTO/GetCheckInListsDTO.php new file mode 100644 index 00000000..ebc04d3d --- /dev/null +++ b/backend/app/Services/Handlers/CheckInList/DTO/GetCheckInListsDTO.php @@ -0,0 +1,16 @@ +checkInListRepository + ->findFirstWhere([ + 'event_id' => $eventId, + 'id' => $checkInListId, + ]); + + if ($checkInList === null) { + throw new ResourceNotFoundException(__('Check-in list not found')); + } + + $this->checkInListRepository->deleteWhere([ + 'id' => $checkInListId, + 'event_id' => $eventId, + ]); + } +} diff --git a/backend/app/Services/Handlers/CheckInList/GetCheckInListHandler.php b/backend/app/Services/Handlers/CheckInList/GetCheckInListHandler.php new file mode 100644 index 00000000..fc442f76 --- /dev/null +++ b/backend/app/Services/Handlers/CheckInList/GetCheckInListHandler.php @@ -0,0 +1,41 @@ +checkInListRepository + ->loadRelation(TicketDomainObject::class) + ->loadRelation(new Relationship(domainObject: EventDomainObject::class, name: 'event')) + ->findFirstWhere([ + 'event_id' => $eventId, + 'id' => $checkInListId, + ]); + + if ($checkInList === null) { + throw new ResourceNotFoundException(__('Check-in list not found')); + } + + $attendeeCheckInCount = $this->checkInListRepository->getCheckedInAttendeeCountById($checkInList->getId()); + + $checkInList->setCheckedInCount($attendeeCheckInCount->checkedInCount ?? 0); + $checkInList->setTotalAttendeesCount($attendeeCheckInCount->totalAttendeesCount ?? 0); + + return $checkInList; + } +} diff --git a/backend/app/Services/Handlers/CheckInList/GetCheckInListsHandler.php b/backend/app/Services/Handlers/CheckInList/GetCheckInListsHandler.php new file mode 100644 index 00000000..be46595b --- /dev/null +++ b/backend/app/Services/Handlers/CheckInList/GetCheckInListsHandler.php @@ -0,0 +1,52 @@ +checkInListRepository + ->loadRelation(TicketDomainObject::class) + ->loadRelation(new Relationship(domainObject: EventDomainObject::class, name: 'event')) + ->findByEventId( + eventId: $dto->eventId, + params: $dto->queryParams, + ); + + if ($checkInLists->isEmpty()) { + return $checkInLists; + } + + $attendeeCheckInCounts = $this->checkInListRepository->getCheckedInAttendeeCountByIds( + $checkInLists->map(fn($checkInList) => $checkInList->getId())->toArray(), + ); + + if ($attendeeCheckInCounts->isEmpty()) { + return $checkInLists; + } + + $checkInLists->each(function (CheckInListDomainObject $checkInList) use ($attendeeCheckInCounts) { + $attendeeCheckInCount = $attendeeCheckInCounts->firstWhere('checkInListId', $checkInList->getId()); + + $checkInList->setCheckedInCount($attendeeCheckInCount->checkedInCount ?? 0); + $checkInList->setTotalAttendeesCount($attendeeCheckInCount->totalAttendeesCount ?? 0); + }); + + return $checkInLists; + } +} diff --git a/backend/app/Services/Handlers/CheckInList/Public/CreateAttendeeCheckInPublicHandler.php b/backend/app/Services/Handlers/CheckInList/Public/CreateAttendeeCheckInPublicHandler.php new file mode 100644 index 00000000..d1256500 --- /dev/null +++ b/backend/app/Services/Handlers/CheckInList/Public/CreateAttendeeCheckInPublicHandler.php @@ -0,0 +1,41 @@ +createAttendeeCheckInService->checkInAttendees( + $checkInData->checkInListUuid, + $checkInData->checkInUserIpAddress, + $checkInData->attendeePublicIds, + ); + + $this->logger->info('Attendee check-ins created', [ + 'attendee_ids' => $checkIns->attendeeCheckIns + ->map(fn(AttendeeCheckInDomainObject $checkIn) => $checkIn->getAttendeeId())->toArray(), + 'check_in_list_uuid' => $checkInData->checkInListUuid, + 'ip_address' => $checkInData->checkInUserIpAddress, + ]); + + return $checkIns; + } +} diff --git a/backend/app/Services/Handlers/CheckInList/Public/DTO/CreateAttendeeCheckInPublicDTO.php b/backend/app/Services/Handlers/CheckInList/Public/DTO/CreateAttendeeCheckInPublicDTO.php new file mode 100644 index 00000000..e915beb3 --- /dev/null +++ b/backend/app/Services/Handlers/CheckInList/Public/DTO/CreateAttendeeCheckInPublicDTO.php @@ -0,0 +1,16 @@ +deleteAttendeeCheckInService->deleteAttendeeCheckIn( + $checkInData->checkInListShortId, + $checkInData->checkInShortId, + ); + + $this->logger->info('Attendee check-in deleted', [ + 'check_in_list_uuid' => $checkInData->checkInListShortId, + 'attendee_public_id' => $checkInData->checkInShortId, + 'check_in_user_ip_address' => $checkInData->checkInUserIpAddress, + ]); + } +} diff --git a/backend/app/Services/Handlers/CheckInList/Public/GetCheckInListAttendeesPublicHandler.php b/backend/app/Services/Handlers/CheckInList/Public/GetCheckInListAttendeesPublicHandler.php new file mode 100644 index 00000000..367c9d23 --- /dev/null +++ b/backend/app/Services/Handlers/CheckInList/Public/GetCheckInListAttendeesPublicHandler.php @@ -0,0 +1,21 @@ +attendeeRepository->getAttendeesByCheckInShortId($shortId, $queryParams); + } +} diff --git a/backend/app/Services/Handlers/CheckInList/Public/GetCheckInListPublicHandler.php b/backend/app/Services/Handlers/CheckInList/Public/GetCheckInListPublicHandler.php new file mode 100644 index 00000000..bbd24480 --- /dev/null +++ b/backend/app/Services/Handlers/CheckInList/Public/GetCheckInListPublicHandler.php @@ -0,0 +1,40 @@ +checkInListRepository + ->loadRelation(new Relationship(domainObject: EventDomainObject::class, name: 'event')) + ->loadRelation(TicketDomainObject::class) + ->findFirstWhere([ + 'short_id' => $shortId, + ]); + + if ($checkInList === null) { + throw new ResourceNotFoundException('Check-in list not found'); + } + + $attendeeCheckInCount = $this->checkInListRepository->getCheckedInAttendeeCountById($checkInList->getId()); + + $checkInList->setCheckedInCount($attendeeCheckInCount->checkedInCount ?? 0); + $checkInList->setTotalAttendeesCount($attendeeCheckInCount->totalAttendeesCount ?? 0); + + return $checkInList; + } +} diff --git a/backend/app/Services/Handlers/CheckInList/UpdateCheckInlistHandler.php b/backend/app/Services/Handlers/CheckInList/UpdateCheckInlistHandler.php new file mode 100644 index 00000000..44307817 --- /dev/null +++ b/backend/app/Services/Handlers/CheckInList/UpdateCheckInlistHandler.php @@ -0,0 +1,36 @@ +setId($data->id) + ->setName($data->name) + ->setDescription($data->description) + ->setEventId($data->eventId) + ->setExpiresAt($data->expiresAt) + ->setActivatesAt($data->activatesAt); + + return $this->updateCheckInlistService->updateCheckInlist( + checkInList: $checkInList, + ticketIds: $data->ticketIds + ); + } +} diff --git a/backend/app/Services/Handlers/Order/CompleteOrderHandler.php b/backend/app/Services/Handlers/Order/CompleteOrderHandler.php index bfe94f6b..cef262b1 100644 --- a/backend/app/Services/Handlers/Order/CompleteOrderHandler.php +++ b/backend/app/Services/Handlers/Order/CompleteOrderHandler.php @@ -113,7 +113,7 @@ private function createAttendees(Collection $attendees, OrderDomainObject $order AttendeeDomainObjectAbstract::LAST_NAME => $attendee->last_name, AttendeeDomainObjectAbstract::ORDER_ID => $order->getId(), AttendeeDomainObjectAbstract::PUBLIC_ID => $order->getPublicId() . '-' . $publicIdIndex++, - AttendeeDomainObjectAbstract::SHORT_ID => IdHelper::randomPrefixedId(IdHelper::ATTENDEE_PREFIX), + AttendeeDomainObjectAbstract::SHORT_ID => IdHelper::shortId(IdHelper::ATTENDEE_PREFIX), AttendeeDomainObjectAbstract::LOCALE => $order->getLocale(), ]; } diff --git a/backend/database/factories/OrderFactory.php b/backend/database/factories/OrderFactory.php index 700e120f..f0a6fe8d 100644 --- a/backend/database/factories/OrderFactory.php +++ b/backend/database/factories/OrderFactory.php @@ -19,7 +19,7 @@ public function definition(): array 'total_tax' => $this->faker->randomFloat(2, 0, 100), 'created_at' => now(), 'updated_at' => now(), - 'short_id' => IdHelper::randomPrefixedId(IdHelper::ORDER_PREFIX), + 'short_id' => IdHelper::shortId(IdHelper::ORDER_PREFIX), 'first_name' => $this->faker->firstName, 'last_name' => $this->faker->lastName, 'email' => $this->faker->safeEmail, diff --git a/backend/database/migrations/2024_08_08_032637_create_check_in_lists_tables.php b/backend/database/migrations/2024_08_08_032637_create_check_in_lists_tables.php new file mode 100644 index 00000000..febfebee --- /dev/null +++ b/backend/database/migrations/2024_08_08_032637_create_check_in_lists_tables.php @@ -0,0 +1,101 @@ +id(); + $table->string('short_id'); + $table->string('name', 100); + $table->text('description')->nullable(); + + $table->timestamp('expires_at')->nullable(); + $table->timestamp('activates_at')->nullable(); + + $table->foreignId('event_id') + ->constrained() + ->onDelete('cascade'); + + $table->softDeletes(); + $table->timestamps(); + + $table->index('event_id'); + $table->index('short_id'); + }); + + Schema::create('ticket_check_in_lists', static function (Blueprint $table) { + $table->id(); + $table->foreignId('ticket_id') + ->constrained() + ->onDelete('cascade'); + + $table->foreignId('check_in_list_id') + ->constrained() + ->onDelete('cascade'); + + $table->softDeletes(); + + $table->index(['ticket_id', 'check_in_list_id']); + }); + + Schema::create('attendee_check_ins', static function (Blueprint $table) { + $table->id(); + $table->string('short_id'); + $table->foreignId('check_in_list_id') + ->constrained() + ->onDelete('cascade'); + + $table->foreignId('ticket_id') + ->constrained() + ->onDelete('cascade'); + + $table->foreignId('attendee_id') + ->constrained() + ->onDelete('cascade'); + + $table->ipAddress(); + + $table->softDeletes(); + $table->timestamps(); + + $table->index('check_in_list_id'); + $table->index('ticket_id'); + $table->index('attendee_id'); + $table->index('short_id'); + }); + + Schema::table('attendees', static function (Blueprint $table) { + // We will remove these columns in the next migration + // $table->dropColumn('checked_in_at'); + // $table->dropColumn('checked_in_by'); + // $table->dropColumn('checked_out_by'); + }); + + DB::statement('CREATE INDEX idx_attendees_ticket_id_deleted_at ON attendees(ticket_id) WHERE deleted_at IS NULL'); + DB::statement('CREATE INDEX idx_ticket_check_in_lists_ticket_id_deleted_at ON ticket_check_in_lists(ticket_id, check_in_list_id) WHERE deleted_at IS NULL'); + DB::statement('CREATE INDEX idx_attendee_check_ins_attendee_id_check_in_list_id_deleted_at ON attendee_check_ins(attendee_id, check_in_list_id) WHERE deleted_at IS NULL'); + + } + + public function down(): void + { + Schema::table('attendees', static function (Blueprint $table) { + // $table->timestamp('checked_in_at')->nullable(); + // $table->foreignId('checked_in_by')->nullable()->constrained('users'); + // $table->foreignId('checked_out_by')->nullable()->constrained('users'); + }); + + Schema::dropIfExists('attendee_check_ins'); + Schema::dropIfExists('ticket_check_in_lists'); + Schema::dropIfExists('check_in_lists'); + + DB::statement('DROP INDEX IF EXISTS idx_attendees_ticket_id_deleted_at'); + DB::statement('DROP INDEX IF EXISTS idx_ticket_check_in_lists_ticket_id_deleted_at'); + DB::statement('DROP INDEX IF EXISTS idx_attendee_check_ins_attendee_id_check_in_list_id_deleted_at'); + } +}; diff --git a/backend/lang/de.json b/backend/lang/de.json index 651f0b86..82b9ffed 100644 --- a/backend/lang/de.json +++ b/backend/lang/de.json @@ -268,5 +268,17 @@ "Price is before sale start date": "Preis ist vor dem Verkaufsstartdatum", "Price is after sale end date": "Preis ist nach dem Verkaufsenddatum", "Price is sold out": "Preis ist ausverkauft", - "Price is hidden": "Preis ist versteckt" + "Price is hidden": "Preis ist versteckt", + "Expires soonest": "Läuft bald ab", + "Expires latest": "Läuft zuletzt ab", + "The expiration date must be after the activation date.": "Das Ablaufdatum muss nach dem Aktivierungsdatum liegen.", + "The activation date must be before the expiration date.": "Das Aktivierungsdatum muss vor dem Ablaufdatum liegen.", + "Attendee :attendee_name is not allowed to check in using this check-in list": "Teilnehmer :attendee_name darf sich mit dieser Eincheckliste nicht einchecken", + "Invalid attendee code detected: :attendees ": "Ungültiger Teilnehmercode erkannt: :attendees", + "Check-in list not found": "Eincheckliste nicht gefunden", + "Attendee :attendee_name is already checked in": "Teilnehmer :attendee_name ist bereits eingecheckt", + "Check-in list has expired": "Die Eincheckliste ist abgelaufen", + "Check-in list is not active yes": "Eincheckliste ist noch nicht aktiv", + "This attendee is not checked in": "Dieser Teilnehmer ist nicht eingecheckt", + "Attendee does not belong to this check-in list": "Teilnehmer gehört nicht zu dieser Eincheckliste" } diff --git a/backend/lang/es.json b/backend/lang/es.json index 1ee27f6f..eb8f353f 100644 --- a/backend/lang/es.json +++ b/backend/lang/es.json @@ -268,5 +268,17 @@ "Price is before sale start date": "El precio es antes de la fecha de inicio de la venta", "Price is after sale end date": "El precio es después de la fecha de finalización de la venta", "Price is sold out": "El precio está agotado", - "Price is hidden": "El precio está oculto" + "Price is hidden": "El precio está oculto", + "Expires soonest": "Expira más pronto", + "Expires latest": "Expira más tarde", + "The expiration date must be after the activation date.": "La fecha de vencimiento debe ser posterior a la fecha de activación.", + "The activation date must be before the expiration date.": "La fecha de activación debe ser anterior a la fecha de vencimiento.", + "Attendee :attendee_name is not allowed to check in using this check-in list": "El asistente :attendee_name no tiene permitido registrarse usando esta lista de registro", + "Invalid attendee code detected: :attendees ": "Código de asistente inválido detectado: :attendees", + "Check-in list not found": "Lista de registro no encontrada", + "Attendee :attendee_name is already checked in": "El asistente :attendee_name ya está registrado", + "Check-in list has expired": "La lista de registro ha expirado", + "Check-in list is not active yes": "La lista de registro aún no está activa", + "This attendee is not checked in": "Este asistente no está registrado", + "Attendee does not belong to this check-in list": "El asistente no pertenece a esta lista de registro" } diff --git a/backend/lang/fr.json b/backend/lang/fr.json index 2f3ceaf6..719d422b 100644 --- a/backend/lang/fr.json +++ b/backend/lang/fr.json @@ -268,5 +268,17 @@ "Price is before sale start date": "Le prix est avant la date de début de la vente", "Price is after sale end date": "Le prix est après la date de fin de la vente", "Price is sold out": "Le prix est épuisé", - "Price is hidden": "Le prix est caché" + "Price is hidden": "Le prix est caché", + "Expires soonest": "Expire le plus tôt", + "Expires latest": "Expire le plus tard", + "The expiration date must be after the activation date.": "La date d'expiration doit être après la date d'activation.", + "The activation date must be before the expiration date.": "La date d'activation doit être avant la date d'expiration.", + "Attendee :attendee_name is not allowed to check in using this check-in list": "Le participant :attendee_name n'est pas autorisé à s'enregistrer avec cette liste de pointage", + "Invalid attendee code detected: :attendees ": "Code de participant invalide détecté : :attendees", + "Check-in list not found": "Liste de pointage non trouvée", + "Attendee :attendee_name is already checked in": "Le participant :attendee_name est déjà enregistré", + "Check-in list has expired": "La liste de pointage a expiré", + "Check-in list is not active yes": "La liste de pointage n'est pas encore active", + "This attendee is not checked in": "Ce participant n'est pas enregistré", + "Attendee does not belong to this check-in list": "Le participant n'appartient pas à cette liste de pointage" } diff --git a/backend/lang/pt-br.json b/backend/lang/pt-br.json index 1e90d39f..c518b715 100644 --- a/backend/lang/pt-br.json +++ b/backend/lang/pt-br.json @@ -268,5 +268,17 @@ "Price is before sale start date": "O preço é antes da data de início da venda", "Price is after sale end date": "O preço é após a data de término da venda", "Price is sold out": "O preço está esgotado", - "Price is hidden": "O preço está oculto" + "Price is hidden": "O preço está oculto", + "Expires soonest": "Expira mais cedo", + "Expires latest": "Expira mais tarde", + "The expiration date must be after the activation date.": "A data de expiração deve ser após a data de ativação.", + "The activation date must be before the expiration date.": "A data de ativação deve ser antes da data de expiração.", + "Attendee :attendee_name is not allowed to check in using this check-in list": "O participante :attendee_name não tem permissão para fazer check-in usando esta lista de check-in", + "Invalid attendee code detected: :attendees ": "Código de participante inválido detectado: :attendees", + "Check-in list not found": "Lista de check-in não encontrada", + "Attendee :attendee_name is already checked in": "O participante :attendee_name já fez check-in", + "Check-in list has expired": "A lista de check-in expirou", + "Check-in list is not active yes": "A lista de check-in ainda não está ativa", + "This attendee is not checked in": "Este participante não fez check-in", + "Attendee does not belong to this check-in list": "O participante não pertence a esta lista de check-in" } diff --git a/backend/lang/pt.json b/backend/lang/pt.json index 5b60a2a3..7056ca56 100644 --- a/backend/lang/pt.json +++ b/backend/lang/pt.json @@ -268,5 +268,17 @@ "Price is before sale start date": "O preço é antes da data de início da venda", "Price is after sale end date": "O preço é após a data de término da venda", "Price is sold out": "O preço está esgotado", - "Price is hidden": "O preço está oculto" + "Price is hidden": "O preço está oculto", + "Expires soonest": "Expira mais cedo", + "Expires latest": "Expira mais tarde", + "The expiration date must be after the activation date.": "A data de expiração deve ser após a data de ativação.", + "The activation date must be before the expiration date.": "A data de ativação deve ser antes da data de expiração.", + "Attendee :attendee_name is not allowed to check in using this check-in list": "O participante :attendee_name não tem permissão para fazer check-in usando esta lista de check-in", + "Invalid attendee code detected: :attendees ": "Código de participante inválido detectado: :attendees", + "Check-in list not found": "Lista de check-in não encontrada", + "Attendee :attendee_name is already checked in": "O participante :attendee_name já fez check-in", + "Check-in list has expired": "A lista de check-in expirou", + "Check-in list is not active yes": "A lista de check-in ainda não está ativa", + "This attendee is not checked in": "Este participante não fez check-in", + "Attendee does not belong to this check-in list": "O participante não pertence a esta lista de check-in" } diff --git a/backend/lang/ru.json b/backend/lang/ru.json index 4df9b1c6..063fe79c 100644 --- a/backend/lang/ru.json +++ b/backend/lang/ru.json @@ -283,5 +283,17 @@ "Price is before sale start date": "", "Price is after sale end date": "", "Price is sold out": "", - "Price is hidden": "" + "Price is hidden": "", + "Expires soonest": "", + "Expires latest": "", + "The expiration date must be after the activation date.": "", + "The activation date must be before the expiration date.": "", + "Attendee :attendee_name is not allowed to check in using this check-in list": "", + "Invalid attendee code detected: :attendees ": "", + "Check-in list not found": "", + "Attendee :attendee_name is already checked in": "", + "Check-in list has expired": "", + "Check-in list is not active yes": "", + "This attendee is not checked in": "", + "Attendee does not belong to this check-in list": "" } \ No newline at end of file diff --git a/backend/lang/zh-cn.json b/backend/lang/zh-cn.json index 96013b68..13c723c0 100644 --- a/backend/lang/zh-cn.json +++ b/backend/lang/zh-cn.json @@ -268,5 +268,17 @@ "Price is before sale start date": "价格在销售开始日期之前", "Price is after sale end date": "价格在销售结束日期之后", "Price is sold out": "价格已售完", - "Price is hidden": "价格被隐藏" + "Price is hidden": "价格被隐藏", + "Expires soonest": "最早到期", + "Expires latest": "最晚到期", + "The expiration date must be after the activation date.": "到期日期必须在激活日期之后。", + "The activation date must be before the expiration date.": "激活日期必须在到期日期之前。", + "Attendee :attendee_name is not allowed to check in using this check-in list": "参与者 :attendee_name 无法使用此签到列表签到", + "Invalid attendee code detected: :attendees ": "检测到无效的参与者代码: :attendees", + "Check-in list not found": "未找到签到列表", + "Attendee :attendee_name is already checked in": "参与者 :attendee_name 已签到", + "Check-in list has expired": "签到列表已过期", + "Check-in list is not active yes": "签到列表尚未激活", + "This attendee is not checked in": "该参与者尚未签到", + "Attendee does not belong to this check-in list": "该参与者不属于此签到列表" } diff --git a/backend/routes/api.php b/backend/routes/api.php index 8133e0aa..540639f1 100644 --- a/backend/routes/api.php +++ b/backend/routes/api.php @@ -26,6 +26,15 @@ use HiEvents\Http\Actions\CapacityAssignments\GetCapacityAssignmentAction; use HiEvents\Http\Actions\CapacityAssignments\GetCapacityAssignmentsAction; use HiEvents\Http\Actions\CapacityAssignments\UpdateCapacityAssignmentAction; +use HiEvents\Http\Actions\CheckInLists\CreateCheckInListAction; +use HiEvents\Http\Actions\CheckInLists\DeleteCheckInListAction; +use HiEvents\Http\Actions\CheckInLists\GetCheckInListAction; +use HiEvents\Http\Actions\CheckInLists\GetCheckInListsAction; +use HiEvents\Http\Actions\CheckInLists\Public\CreateAttendeeCheckInPublicAction; +use HiEvents\Http\Actions\CheckInLists\Public\DeleteAttendeeCheckInPublicAction; +use HiEvents\Http\Actions\CheckInLists\Public\GetCheckInListAttendeesPublicAction; +use HiEvents\Http\Actions\CheckInLists\Public\GetCheckInListPublicAction; +use HiEvents\Http\Actions\CheckInLists\UpdateCheckInListAction; use HiEvents\Http\Actions\Common\Webhooks\StripeIncomingWebhookAction; use HiEvents\Http\Actions\Events\CreateEventAction; use HiEvents\Http\Actions\Events\DuplicateEventAction; @@ -218,6 +227,12 @@ function (Router $router): void { $router->get('/events/{event_id}/capacity-assignments/{capacity_assignment_id}', GetCapacityAssignmentAction::class); $router->put('/events/{event_id}/capacity-assignments/{capacity_assignment_id}', UpdateCapacityAssignmentAction::class); $router->delete('/events/{event_id}/capacity-assignments/{capacity_assignment_id}', DeleteCapacityAssignmentAction::class); + + $router->post('/events/{event_id}/check-in-lists', CreateCheckInListAction::class); + $router->get('/events/{event_id}/check-in-lists', GetCheckInListsAction::class); + $router->get('/events/{event_id}/check-in-lists/{check_in_list_id}', GetCheckInListAction::class); + $router->put('/events/{event_id}/check-in-lists/{check_in_list_id}', UpdateCheckInListAction::class); + $router->delete('/events/{event_id}/check-in-lists/{check_in_list_id}', DeleteCheckInListAction::class); } ); @@ -226,25 +241,38 @@ function (Router $router): void { */ $router->prefix('/public')->group( function (Router $router): void { + // Events $router->get('/events/{event_id}', GetEventPublicAction::class); + + // Tickets $router->get('/events/{event_id}/tickets', GetEventPublicAction::class); + // Orders $router->post('/events/{event_id}/order', CreateOrderActionPublic::class); $router->put('/events/{event_id}/order/{order_short_id}', CompleteOrderActionPublic::class); $router->get('/events/{event_id}/order/{order_short_id}', GetOrderActionPublic::class); + // Attendees $router->get('/events/{event_id}/attendees/{attendee_short_id}', GetAttendeeActionPublic::class); + // Promo codes $router->get('/events/{event_id}/promo-codes/{promo_code}', GetPromoCodePublic::class); // Stripe payment gateway $router->post('/events/{event_id}/order/{order_short_id}/stripe/payment_intent', CreatePaymentIntentActionPublic::class); $router->get('/events/{event_id}/order/{order_short_id}/stripe/payment_intent', GetPaymentIntentActionPublic::class); + // Questions $router->get('/events/{event_id}/questions', GetQuestionsPublicAction::class); // Webhooks $router->post('/webhooks/stripe', StripeIncomingWebhookAction::class); + + // Check-In + $router->get('/check-in-lists/{check_in_list_short_id}', GetCheckInListPublicAction::class); + $router->get('/check-in-lists/{check_in_list_short_id}/attendees', GetCheckInListAttendeesPublicAction::class); + $router->post('/check-in-lists/{check_in_list_short_id}/check-ins', CreateAttendeeCheckInPublicAction::class); + $router->delete('/check-in-lists/{check_in_list_short_id}/check-ins/{check_in_short_id}', DeleteAttendeeCheckInPublicAction::class); } ); diff --git a/frontend/public/blank-slate/check-in-lists.svg b/frontend/public/blank-slate/check-in-lists.svg new file mode 100644 index 00000000..84b59644 --- /dev/null +++ b/frontend/public/blank-slate/check-in-lists.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/api/check-in-list.client.ts b/frontend/src/api/check-in-list.client.ts new file mode 100644 index 00000000..2f347cec --- /dev/null +++ b/frontend/src/api/check-in-list.client.ts @@ -0,0 +1,32 @@ +import {api} from "./client"; +import { + CheckInList, + CheckInListRequest, + GenericDataResponse, + GenericPaginatedResponse, + IdParam, QueryFilters, +} from "../types"; +import {queryParamsHelper} from "../utilites/queryParamsHelper.ts"; + +export const checkInListClient = { + create: async (eventId: IdParam, checkInList: CheckInListRequest) => { + const response = await api.post>(`events/${eventId}/check-in-lists`, checkInList); + return response.data; + }, + update: async (eventId: IdParam, checkInListId: IdParam, checkInList: CheckInListRequest) => { + const response = await api.put>(`events/${eventId}/check-in-lists/${checkInListId}`, checkInList); + return response.data; + }, + all: async (eventId: IdParam, pagination: QueryFilters) => { + const response = await api.get>(`events/${eventId}/check-in-lists` + queryParamsHelper.buildQueryString(pagination)); + return response.data; + }, + get: async (eventId: IdParam, checkInListId: IdParam) => { + const response = await api.get>(`events/${eventId}/check-in-lists/${checkInListId}`); + return response.data; + }, + delete: async (eventId: IdParam, checkInListId: IdParam) => { + const response = await api.delete>(`events/${eventId}/check-in-lists/${checkInListId}`); + return response.data; + }, +} diff --git a/frontend/src/api/check-in.client.ts b/frontend/src/api/check-in.client.ts new file mode 100644 index 00000000..d932e52a --- /dev/null +++ b/frontend/src/api/check-in.client.ts @@ -0,0 +1,32 @@ +import {publicApi} from "./public-client"; +import { + Attendee, + CheckIn, + CheckInList, + GenericDataResponse, + GenericPaginatedResponse, + IdParam, + QueryFilters, +} from "../types"; +import {queryParamsHelper} from "../utilites/queryParamsHelper"; + +export const publicCheckInClient = { + getCheckInList: async (checkInListShortId: IdParam) => { + const response = await publicApi.get>(`/check-in-lists/${checkInListShortId}`); + return response.data; + }, + getCheckInListAttendees: async (checkInListShortId: IdParam, pagination: QueryFilters) => { + const response = await publicApi.get>(`/check-in-lists/${checkInListShortId}/attendees` + queryParamsHelper.buildQueryString(pagination)); + return response.data; + }, + createCheckIn: async (checkInListShortId: IdParam, attendeePublicId: IdParam) => { + const response = await publicApi.post>(`/check-in-lists/${checkInListShortId}/check-ins`, { + "attendee_public_ids": [attendeePublicId], + }); + return response.data; + }, + deleteCheckIn: async (checkInListShortId: IdParam, checkInShortId: IdParam) => { + const response = await publicApi.delete>(`/check-in-lists/${checkInListShortId}/check-ins/${checkInShortId}`); + return response.data; + }, +}; diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 5f71d361..67342e3c 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -22,7 +22,8 @@ const ALLOWED_UNAUTHENTICATED_PATHS = [ 'print', '/order/', 'widget', - '/ticket/' + '/ticket/', + 'check-in', ]; export const api = axios.create({ diff --git a/frontend/src/components/common/AttendeeCheckInTable/QrScanner.tsx b/frontend/src/components/common/AttendeeCheckInTable/QrScanner.tsx index a818d6a1..f36f8ab2 100644 --- a/frontend/src/components/common/AttendeeCheckInTable/QrScanner.tsx +++ b/frontend/src/components/common/AttendeeCheckInTable/QrScanner.tsx @@ -54,6 +54,11 @@ export const QRScannerComponent = (props: QRScannerComponentProps) => { const latestProcessedAttendeeIds = latestProcessedAttendeeIdsRef.current; const alreadyScanned = latestProcessedAttendeeIds.includes(debouncedAttendeeId); + if (alreadyScanned) { + showError(t`You already scanned this ticket`); + return; + } + if (!isCheckingIn && !alreadyScanned) { setIsCheckingIn(true); props.onCheckIn(debouncedAttendeeId, () => { @@ -129,7 +134,8 @@ export const QRScannerComponent = (props: QRScannerComponentProps) => { {permissionDenied && (
- Camera permission was denied. Request Permission again, + Camera permission was denied. Request + Permission again, or if this doesn't work, you will need to grant @@ -171,4 +177,4 @@ export const QRScannerComponent = (props: QRScannerComponentProps) => {
); -}; \ No newline at end of file +}; diff --git a/frontend/src/components/common/CheckInListList/CheckInListList.module.scss b/frontend/src/components/common/CheckInListList/CheckInListList.module.scss new file mode 100644 index 00000000..89d42599 --- /dev/null +++ b/frontend/src/components/common/CheckInListList/CheckInListList.module.scss @@ -0,0 +1,95 @@ +@import '../../../styles/mixins'; + +.toolbar { + display: flex; + justify-content: space-between; + align-items: center; + + @include respond-below(md) { + flex-direction: column; + align-items: flex-start; + } + + .search { + width: 80%; + margin-right: 20px; + max-width: 320px; + + @include respond-below(md) { + width: 100%; + margin-bottom: 20px; + max-width: 100%; + } + } + + .button { + display: flex; + place-content: flex-end; + + button { + height: 42px; + } + + @include respond-below(md) { + width: 100%; + } + } +} + +.checkInListList { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); + gap: 20px; + margin-top: 20px; + + .checkInListCard { + display: flex; + flex-direction: column; + justify-content: space-between; + margin-bottom: 0 !important; + + .checkInListHeader { + display: flex; + justify-content: space-between; + align-items: center; + border-radius: 5px; + margin-bottom: 20px; + + .checkInListAppliesTo { + .appliesToText { + display: flex; + color: #999; + justify-content: center; + align-items: center; + } + } + } + + .checkInListName { + font-size: 1.2rem; + font-weight: bold; + margin-bottom: 10px; + } + + .checkInListInfo { + display: flex; + justify-content: space-between; + align-items: center; + + .checkInListCapacity { + margin-bottom: 4px; + width: 120px; + + .capacityText { + margin-top: 10px; + color: #999; + } + } + + .checkInListActions { + + } + } + } +} + diff --git a/frontend/src/components/common/CheckInListList/index.tsx b/frontend/src/components/common/CheckInListList/index.tsx new file mode 100644 index 00000000..7bf9b20a --- /dev/null +++ b/frontend/src/components/common/CheckInListList/index.tsx @@ -0,0 +1,187 @@ +import {CheckInList, IdParam} from "../../../types"; +import {Badge, Button, Progress} from "@mantine/core"; +import {t, Trans} from "@lingui/macro"; +import {IconHelp, IconPencil, IconPlus, IconTrash} from "@tabler/icons-react"; +import Truncate from "../Truncate"; +import {NoResultsSplash} from "../NoResultsSplash"; +import classes from './CheckInListList.module.scss'; +import {Card} from "../Card"; +import {Popover} from "../Popover"; +import {useState} from "react"; +import {ActionMenu} from "../ActionMenu"; +import {useDisclosure} from "@mantine/hooks"; +import {EditCheckInListModal} from "../../modals/EditCheckInListModal"; +import {useDeleteCheckInList} from "../../../mutations/useDeleteCheckInList"; +import {showError, showSuccess} from "../../../utilites/notifications.tsx"; +import {confirmationDialog} from "../../../utilites/confirmationDialog.tsx"; + +interface CheckInListListProps { + checkInLists: CheckInList[]; + openCreateModal: () => void; +} + +export const CheckInListList = ({checkInLists, openCreateModal}: CheckInListListProps) => { + const [editModalOpen, {open: openEditModal, close: closeEditModal}] = useDisclosure(false); + const [selectedCheckInListId, setSelectedCheckInListId] = useState(); + const deleteMutation = useDeleteCheckInList(); + + const handleDeleteCheckInList = (checkInListId: IdParam, eventId: IdParam) => { + deleteMutation.mutate({checkInListId, eventId}, { + onSuccess: () => { + showSuccess(t`Check-In List deleted successfully`); + }, + onError: (error: any) => { + showError(error.message); + } + }); + } + + if (checkInLists.length === 0) { + return ( + +

+ +

+ Check-in lists help manage attendee entry for your event. You can associate multiple + tickets with a check-in list and ensure only those with valid tickets can enter. +

+
+

+ + + )} + /> + ); + } + + return ( + <> +
+ {checkInLists.map((list) => { + const statusMessage = (function () { + if (list.is_expired) { + return t`This check-in list has expired`; + } + + if (!list.is_active) { + return t`This check-in list is not active yet`; + } + + return t`This check-in list is active`; + } + )(); + + return ( + +
+
+ {list.tickets && ( + ( +
+ {ticket.title} +
+ ))} + position={'bottom'} + withArrow + > +
+
+ {list.tickets.length > 1 && + Includes {list.tickets.length} tickets} + {list.tickets.length === 1 && t`Includes 1 ticket`} +
+   + +
+
+ )} +
+
+ + + {!list.is_expired && list.is_active ? t`Active` : t`Inactive`} + + +
+ +
+
+ + + +
+ +
+
+ +
+ {list.checked_in_attendees} / {list.total_attendees} +
+
+
+ , + onClick: () => { + setSelectedCheckInListId(list.id as IdParam); + openEditModal(); + } + }, + ], + }, + { + label: t`Danger zone`, + items: [ + { + label: t`Delete Check-In List`, + icon: , + onClick: () => { + confirmationDialog( + t`Are you sure you would like to delete this Check-In List?`, + () => { + handleDeleteCheckInList( + list.id as IdParam, + list.event_id as IdParam, + ); + }) + }, + color: 'red', + }, + ], + }, + ]} + /> +
+
+
+ ); + })} +
+ {(editModalOpen && selectedCheckInListId) + && } + + ); +}; diff --git a/frontend/src/components/common/Countdown/index.tsx b/frontend/src/components/common/Countdown/index.tsx index a31607f7..f7f9bfb4 100644 --- a/frontend/src/components/common/Countdown/index.tsx +++ b/frontend/src/components/common/Countdown/index.tsx @@ -1,7 +1,7 @@ -import {useEffect, useState} from 'react'; +import { useEffect, useState } from 'react'; import dayjs from 'dayjs'; import utc from 'dayjs/plugin/utc'; -import {t} from '@lingui/macro'; +import { t } from '@lingui/macro'; dayjs.extend(utc); @@ -11,7 +11,7 @@ interface CountdownProps { className?: string; } -export const Countdown = ({targetDate, onExpiry, className = ''}: CountdownProps) => { +export const Countdown = ({ targetDate, onExpiry, className = '' }: CountdownProps) => { const [timeLeft, setTimeLeft] = useState(''); useEffect(() => { @@ -29,9 +29,18 @@ export const Countdown = ({targetDate, onExpiry, className = ''}: CountdownProps return; } - const minutes = Math.floor(diff / 1000 / 60); + const days = Math.floor(diff / (1000 * 60 * 60 * 24)); + const hours = Math.floor((diff / (1000 * 60 * 60)) % 24); + const minutes = Math.floor((diff / 1000 / 60) % 60); const seconds = Math.floor((diff / 1000) % 60); - setTimeLeft(t`${minutes} minutes and ${seconds} seconds`); + + if (days > 0) { + setTimeLeft(t`${days} days, ${hours} hours, ${minutes} minutes, and ${seconds} seconds`); + } else if (hours > 0) { + setTimeLeft(t`${hours} hours, ${minutes} minutes, and ${seconds} seconds`); + } else { + setTimeLeft(t`${minutes} minutes and ${seconds} seconds`); + } }, 1000); return () => { diff --git a/frontend/src/components/common/Header/Header.module.scss b/frontend/src/components/common/Header/Header.module.scss index b713233e..e5e0eb06 100644 --- a/frontend/src/components/common/Header/Header.module.scss +++ b/frontend/src/components/common/Header/Header.module.scss @@ -3,6 +3,7 @@ background-color: var(--tk-primary); border-bottom: 1px solid var(--mantine-color-gray-3); + .logo { display: flex; img { diff --git a/frontend/src/components/common/Header/index.tsx b/frontend/src/components/common/Header/index.tsx index ead2073a..f045f5ff 100644 --- a/frontend/src/components/common/Header/index.tsx +++ b/frontend/src/components/common/Header/index.tsx @@ -1,17 +1,24 @@ import {Container} from '@mantine/core'; import classes from './Header.module.scss'; -import {GlobalMenu} from "../GlobalMenu"; import {NavLink} from "react-router-dom"; -export const Header = () => { +interface HeaderProps { + rightContent?: React.ReactNode; + fullWidth?: boolean; +} + +export const Header = ({rightContent, fullWidth = false}: HeaderProps) => { return (
- + Hi.Events logo - + +
+ {rightContent} +
); -} \ No newline at end of file +} diff --git a/frontend/src/components/forms/CheckInListForm/index.tsx b/frontend/src/components/forms/CheckInListForm/index.tsx new file mode 100644 index 00000000..28666a10 --- /dev/null +++ b/frontend/src/components/forms/CheckInListForm/index.tsx @@ -0,0 +1,70 @@ +import {MultiSelect, Textarea, TextInput} from "@mantine/core"; +import {t} from "@lingui/macro"; +import {UseFormReturnType} from "@mantine/form"; +import {CheckInListRequest, Ticket} from "../../../types.ts"; +import {InputLabelWithHelp} from "../../common/InputLabelWithHelp"; +import {InputGroup} from "../../common/InputGroup"; +import {IconTicket} from "@tabler/icons-react"; + +interface CheckInListFormProps { + form: UseFormReturnType; + tickets: Ticket[], +} + +export const CheckInListForm = ({form, tickets}: CheckInListFormProps) => { + return ( + <> + + + { + return { + value: String(ticket.id), + label: ticket.title, + } + })} + required + leftSection={} + {...form.getInputProps('ticket_ids')} + /> + +