diff --git a/backend/app/DomainObjects/Enums/OrderAuditAction.php b/backend/app/DomainObjects/Enums/OrderAuditAction.php new file mode 100644 index 0000000000..d0be0f8ca4 --- /dev/null +++ b/backend/app/DomainObjects/Enums/OrderAuditAction.php @@ -0,0 +1,13 @@ + $this->show_marketing_opt_in ?? null, 'homepage_theme_settings' => $this->homepage_theme_settings ?? null, 'pass_platform_fee_to_buyer' => $this->pass_platform_fee_to_buyer ?? null, + 'allow_attendee_self_edit' => $this->allow_attendee_self_edit ?? null, ]; } @@ -760,4 +763,15 @@ public function getPassPlatformFeeToBuyer(): bool { return $this->pass_platform_fee_to_buyer; } + + public function setAllowAttendeeSelfEdit(bool $allow_attendee_self_edit): self + { + $this->allow_attendee_self_edit = $allow_attendee_self_edit; + return $this; + } + + public function getAllowAttendeeSelfEdit(): bool + { + return $this->allow_attendee_self_edit; + } } diff --git a/backend/app/DomainObjects/Generated/OrderAuditLogDomainObjectAbstract.php b/backend/app/DomainObjects/Generated/OrderAuditLogDomainObjectAbstract.php new file mode 100644 index 0000000000..2cb6ac7c0c --- /dev/null +++ b/backend/app/DomainObjects/Generated/OrderAuditLogDomainObjectAbstract.php @@ -0,0 +1,188 @@ + $this->id ?? null, + 'event_id' => $this->event_id ?? null, + 'order_id' => $this->order_id ?? null, + 'attendee_id' => $this->attendee_id ?? null, + 'action' => $this->action ?? null, + 'old_values' => $this->old_values ?? null, + 'new_values' => $this->new_values ?? null, + 'changed_fields' => $this->changed_fields ?? null, + 'ip_address' => $this->ip_address ?? null, + 'user_agent' => $this->user_agent ?? 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 setOrderId(int $order_id): self + { + $this->order_id = $order_id; + return $this; + } + + public function getOrderId(): int + { + return $this->order_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 setAction(string $action): self + { + $this->action = $action; + return $this; + } + + public function getAction(): string + { + return $this->action; + } + + public function setOldValues(array|string|null $old_values): self + { + $this->old_values = $old_values; + return $this; + } + + public function getOldValues(): array|string|null + { + return $this->old_values; + } + + public function setNewValues(array|string|null $new_values): self + { + $this->new_values = $new_values; + return $this; + } + + public function getNewValues(): array|string|null + { + return $this->new_values; + } + + public function setChangedFields(?string $changed_fields): self + { + $this->changed_fields = $changed_fields; + return $this; + } + + public function getChangedFields(): ?string + { + return $this->changed_fields; + } + + public function setIpAddress(?string $ip_address): self + { + $this->ip_address = $ip_address; + return $this; + } + + public function getIpAddress(): ?string + { + return $this->ip_address; + } + + public function setUserAgent(?string $user_agent): self + { + $this->user_agent = $user_agent; + return $this; + } + + public function getUserAgent(): ?string + { + return $this->user_agent; + } + + 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/OrderItemDomainObjectAbstract.php b/backend/app/DomainObjects/Generated/OrderItemDomainObjectAbstract.php index ecb073ac00..076da8954c 100644 --- a/backend/app/DomainObjects/Generated/OrderItemDomainObjectAbstract.php +++ b/backend/app/DomainObjects/Generated/OrderItemDomainObjectAbstract.php @@ -25,8 +25,6 @@ abstract class OrderItemDomainObjectAbstract extends \HiEvents\DomainObjects\Abs final public const TOTAL_SERVICE_FEE = 'total_service_fee'; final public const TAXES_AND_FEES_ROLLUP = 'taxes_and_fees_rollup'; final public const PRODUCT_TYPE = 'product_type'; - final public const BUNDLE_GROUP_ID = 'bundle_group_id'; - final public const IS_BUNDLE_PRIMARY = 'is_bundle_primary'; protected int $id; protected int $order_id; @@ -43,8 +41,6 @@ abstract class OrderItemDomainObjectAbstract extends \HiEvents\DomainObjects\Abs protected ?float $total_service_fee = 0.0; protected array|string|null $taxes_and_fees_rollup = null; protected string $product_type = 'TICKET'; - protected ?string $bundle_group_id = null; - protected bool $is_bundle_primary = false; public function toArray(): array { @@ -64,8 +60,6 @@ public function toArray(): array 'total_service_fee' => $this->total_service_fee ?? null, 'taxes_and_fees_rollup' => $this->taxes_and_fees_rollup ?? null, 'product_type' => $this->product_type ?? null, - 'bundle_group_id' => $this->bundle_group_id ?? null, - 'is_bundle_primary' => $this->is_bundle_primary ?? null, ]; } @@ -233,26 +227,4 @@ public function getProductType(): string { return $this->product_type; } - - public function setBundleGroupId(?string $bundle_group_id): self - { - $this->bundle_group_id = $bundle_group_id; - return $this; - } - - public function getBundleGroupId(): ?string - { - return $this->bundle_group_id; - } - - public function setIsBundlePrimary(bool $is_bundle_primary): self - { - $this->is_bundle_primary = $is_bundle_primary; - return $this; - } - - public function getIsBundlePrimary(): bool - { - return $this->is_bundle_primary; - } } diff --git a/backend/app/DomainObjects/Generated/OrganizerSettingDomainObjectAbstract.php b/backend/app/DomainObjects/Generated/OrganizerSettingDomainObjectAbstract.php index fa2de3ceb0..167a2dfda9 100644 --- a/backend/app/DomainObjects/Generated/OrganizerSettingDomainObjectAbstract.php +++ b/backend/app/DomainObjects/Generated/OrganizerSettingDomainObjectAbstract.php @@ -28,6 +28,7 @@ abstract class OrganizerSettingDomainObjectAbstract extends \HiEvents\DomainObje final public const DEFAULT_ATTENDEE_DETAILS_COLLECTION_METHOD = 'default_attendee_details_collection_method'; final public const DEFAULT_SHOW_MARKETING_OPT_IN = 'default_show_marketing_opt_in'; final public const DEFAULT_PASS_PLATFORM_FEE_TO_BUYER = 'default_pass_platform_fee_to_buyer'; + final public const DEFAULT_ALLOW_ATTENDEE_SELF_EDIT = 'default_allow_attendee_self_edit'; protected int $id; protected int $organizer_id; @@ -47,6 +48,7 @@ abstract class OrganizerSettingDomainObjectAbstract extends \HiEvents\DomainObje protected string $default_attendee_details_collection_method = 'PER_TICKET'; protected bool $default_show_marketing_opt_in = true; protected bool $default_pass_platform_fee_to_buyer = false; + protected bool $default_allow_attendee_self_edit = true; public function toArray(): array { @@ -69,6 +71,7 @@ public function toArray(): array 'default_attendee_details_collection_method' => $this->default_attendee_details_collection_method ?? null, 'default_show_marketing_opt_in' => $this->default_show_marketing_opt_in ?? null, 'default_pass_platform_fee_to_buyer' => $this->default_pass_platform_fee_to_buyer ?? null, + 'default_allow_attendee_self_edit' => $this->default_allow_attendee_self_edit ?? null, ]; } @@ -270,4 +273,15 @@ public function getDefaultPassPlatformFeeToBuyer(): bool { return $this->default_pass_platform_fee_to_buyer; } + + public function setDefaultAllowAttendeeSelfEdit(bool $default_allow_attendee_self_edit): self + { + $this->default_allow_attendee_self_edit = $default_allow_attendee_self_edit; + return $this; + } + + public function getDefaultAllowAttendeeSelfEdit(): bool + { + return $this->default_allow_attendee_self_edit; + } } diff --git a/backend/app/DomainObjects/OrderAuditLogDomainObject.php b/backend/app/DomainObjects/OrderAuditLogDomainObject.php new file mode 100644 index 0000000000..511fabdfde --- /dev/null +++ b/backend/app/DomainObjects/OrderAuditLogDomainObject.php @@ -0,0 +1,7 @@ +isActionAuthorized($organizerId, OrganizerDomainObject::class); + + $this->validateDateRange($request); + + if (!in_array($reportType, OrganizerReportTypes::valuesArray(), true)) { + throw new BadRequestHttpException(__('Invalid report type.')); + } + + $reportData = $this->reportHandler->handle( + reportData: new GetOrganizerReportDTO( + organizerId: $organizerId, + reportType: OrganizerReportTypes::from($reportType), + startDate: $request->validated('start_date'), + endDate: $request->validated('end_date'), + currency: $request->validated('currency'), + eventId: $request->validated('event_id'), + page: 1, + perPage: self::MAX_EXPORT_ROWS, + ), + ); + + $data = $reportData instanceof PaginatedReportDTO + ? $reportData->data + : $reportData; + + $filename = $reportType . '_' . date('Y-m-d_H-i-s') . '.csv'; + + return new StreamedResponse(function () use ($data, $reportType) { + $handle = fopen('php://output', 'w'); + + $headers = $this->getHeadersForReportType($reportType); + fputcsv($handle, $headers); + + foreach ($data as $row) { + $csvRow = $this->formatRowForReportType($row, $reportType); + fputcsv($handle, $csvRow); + } + + fclose($handle); + }, 200, [ + 'Content-Type' => 'text/csv', + 'Content-Disposition' => "attachment; filename=\"$filename\"", + 'Cache-Control' => 'no-cache, no-store, must-revalidate', + 'Pragma' => 'no-cache', + 'Expires' => '0', + ]); + } + + private function getHeadersForReportType(string $reportType): array + { + return match ($reportType) { + OrganizerReportTypes::PLATFORM_FEES->value => [ + 'Event', + 'Payment Date', + 'Order Reference', + 'Amount Paid', + 'Hi.Events Fee', + 'VAT Rate', + 'VAT on Fee', + 'Total Fee', + 'Currency', + 'Stripe Payment ID', + ], + OrganizerReportTypes::REVENUE_SUMMARY->value => [ + 'Date', + 'Gross Sales', + 'Net Revenue', + 'Total Refunded', + 'Total Tax', + 'Total Fee', + 'Order Count', + ], + OrganizerReportTypes::EVENTS_PERFORMANCE->value => [ + 'Event ID', + 'Event Name', + 'Currency', + 'Start Date', + 'End Date', + 'Status', + 'Event State', + 'Products Sold', + 'Gross Revenue', + 'Total Refunded', + 'Net Revenue', + 'Total Tax', + 'Total Fee', + 'Total Orders', + 'Unique Customers', + 'Page Views', + ], + OrganizerReportTypes::TAX_SUMMARY->value => [ + 'Event ID', + 'Event Name', + 'Currency', + 'Tax Name', + 'Tax Rate', + 'Total Collected', + 'Order Count', + ], + OrganizerReportTypes::CHECK_IN_SUMMARY->value => [ + 'Event ID', + 'Event Name', + 'Start Date', + 'Total Attendees', + 'Total Checked In', + 'Check-in Rate (%)', + 'Check-in Lists Count', + ], + default => [], + }; + } + + private function formatRowForReportType(object $row, string $reportType): array + { + return match ($reportType) { + OrganizerReportTypes::PLATFORM_FEES->value => [ + $row->event_name ?? '', + $row->payment_date ? date('Y-m-d H:i:s', strtotime($row->payment_date)) : '', + $row->order_reference ?? '', + $row->amount_paid ?? 0, + $row->fee_amount ?? 0, + $row->vat_rate !== null ? ($row->vat_rate * 100) . '%' : '', + $row->vat_amount ?? 0, + $row->total_fee ?? 0, + $row->currency ?? '', + $row->payment_intent_id ?? '', + ], + OrganizerReportTypes::REVENUE_SUMMARY->value => [ + $row->date ?? '', + $row->gross_sales ?? 0, + $row->net_revenue ?? 0, + $row->total_refunded ?? 0, + $row->total_tax ?? 0, + $row->total_fee ?? 0, + $row->order_count ?? 0, + ], + OrganizerReportTypes::EVENTS_PERFORMANCE->value => [ + $row->event_id ?? '', + $row->event_name ?? '', + $row->event_currency ?? '', + $row->start_date ?? '', + $row->end_date ?? '', + $row->status ?? '', + $row->event_state ?? '', + $row->products_sold ?? 0, + $row->gross_revenue ?? 0, + $row->total_refunded ?? 0, + $row->net_revenue ?? 0, + $row->total_tax ?? 0, + $row->total_fee ?? 0, + $row->total_orders ?? 0, + $row->unique_customers ?? 0, + $row->page_views ?? 0, + ], + OrganizerReportTypes::TAX_SUMMARY->value => [ + $row->event_id ?? '', + $row->event_name ?? '', + $row->event_currency ?? '', + $row->tax_name ?? '', + $row->tax_rate ? ($row->tax_rate * 100) . '%' : '', + $row->total_collected ?? 0, + $row->order_count ?? 0, + ], + OrganizerReportTypes::CHECK_IN_SUMMARY->value => [ + $row->event_id ?? '', + $row->event_name ?? '', + $row->start_date ?? '', + $row->total_attendees ?? 0, + $row->total_checked_in ?? 0, + $row->check_in_rate ?? 0, + $row->check_in_lists_count ?? 0, + ], + default => [], + }; + } + + /** + * @throws ValidationException + */ + private function validateDateRange(GetOrganizerReportRequest $request): void + { + $startDate = $request->validated('start_date'); + $endDate = $request->validated('end_date'); + + if (!$startDate || !$endDate) { + return; + } + + $diffInDays = Carbon::parse($startDate)->diffInDays(Carbon::parse($endDate)); + + if ($diffInDays > 370) { + throw ValidationException::withMessages(['start_date' => __('Date range must be less than 370 days.')]); + } + } +} diff --git a/backend/app/Http/Actions/Reports/GetOrganizerReportAction.php b/backend/app/Http/Actions/Reports/GetOrganizerReportAction.php index 21f3505b45..e97a87f7d9 100644 --- a/backend/app/Http/Actions/Reports/GetOrganizerReportAction.php +++ b/backend/app/Http/Actions/Reports/GetOrganizerReportAction.php @@ -8,6 +8,7 @@ use HiEvents\Http\Request\Report\GetOrganizerReportRequest; use HiEvents\Services\Application\Handlers\Reports\DTO\GetOrganizerReportDTO; use HiEvents\Services\Application\Handlers\Reports\GetOrganizerReportHandler; +use HiEvents\Services\Domain\Report\DTO\PaginatedReportDTO; use Illuminate\Http\JsonResponse; use Illuminate\Support\Carbon; use Illuminate\Validation\ValidationException; @@ -39,9 +40,18 @@ public function __invoke(GetOrganizerReportRequest $request, int $organizerId, s startDate: $request->validated('start_date'), endDate: $request->validated('end_date'), currency: $request->validated('currency'), + eventId: $request->validated('event_id'), + page: (int) $request->validated('page', 1), + perPage: (int) $request->validated('per_page', 1000), ), ); + if ($reportData instanceof PaginatedReportDTO) { + return $this->jsonResponse( + data: $reportData->toArray(), + ); + } + return $this->jsonResponse( data: $reportData, wrapInData: true, diff --git a/backend/app/Http/Actions/SelfService/EditAttendeePublicAction.php b/backend/app/Http/Actions/SelfService/EditAttendeePublicAction.php new file mode 100644 index 0000000000..e1837688f1 --- /dev/null +++ b/backend/app/Http/Actions/SelfService/EditAttendeePublicAction.php @@ -0,0 +1,53 @@ +handler->handle(EditAttendeePublicDTO::from([ + 'eventId' => $eventId, + 'orderShortId' => $orderShortId, + 'attendeeShortId' => $attendeeShortId, + 'firstName' => $request->input('first_name'), + 'lastName' => $request->input('last_name'), + 'email' => $request->input('email'), + 'ipAddress' => $this->getClientIp($request), + 'userAgent' => $request->userAgent(), + ])); + + $response = [ + 'message' => __('Attendee updated successfully'), + ]; + + if ($result->shortIdChanged && $result->newShortId) { + $response['new_short_id'] = $result->newShortId; + } + + return $this->jsonResponse($response); + } catch (SelfServiceDisabledException $e) { + return $this->errorResponse($e->getMessage(), $e->getCode()); + } catch (ResourceNotFoundException $e) { + return $this->errorResponse($e->getMessage(), 404); + } + } +} diff --git a/backend/app/Http/Actions/SelfService/EditOrderPublicAction.php b/backend/app/Http/Actions/SelfService/EditOrderPublicAction.php new file mode 100644 index 0000000000..6a9724b55f --- /dev/null +++ b/backend/app/Http/Actions/SelfService/EditOrderPublicAction.php @@ -0,0 +1,51 @@ +handler->handle(EditOrderPublicDTO::from([ + 'eventId' => $eventId, + 'orderShortId' => $orderShortId, + 'firstName' => $request->input('first_name'), + 'lastName' => $request->input('last_name'), + 'email' => $request->input('email'), + 'ipAddress' => $this->getClientIp($request), + 'userAgent' => $request->userAgent(), + ])); + + $response = [ + 'message' => __('Order updated successfully'), + ]; + + if ($result->shortIdChanged && $result->newShortId) { + $response['new_short_id'] = $result->newShortId; + } + + return $this->jsonResponse($response); + } catch (SelfServiceDisabledException $e) { + return $this->errorResponse($e->getMessage(), $e->getCode()); + } catch (ResourceNotFoundException $e) { + return $this->errorResponse($e->getMessage(), 404); + } + } +} diff --git a/backend/app/Http/Actions/SelfService/ResendAttendeeTicketPublicAction.php b/backend/app/Http/Actions/SelfService/ResendAttendeeTicketPublicAction.php new file mode 100644 index 0000000000..594f7c2121 --- /dev/null +++ b/backend/app/Http/Actions/SelfService/ResendAttendeeTicketPublicAction.php @@ -0,0 +1,44 @@ +handler->handle(ResendEmailPublicDTO::from([ + 'eventId' => $eventId, + 'orderShortId' => $orderShortId, + 'attendeeShortId' => $attendeeShortId, + 'ipAddress' => $this->getClientIp($request), + 'userAgent' => $request->userAgent(), + ])); + + return $this->jsonResponse([ + 'message' => __('Ticket resent successfully'), + ]); + } catch (SelfServiceDisabledException $e) { + return $this->errorResponse($e->getMessage(), $e->getCode()); + } catch (ResourceNotFoundException $e) { + return $this->errorResponse($e->getMessage(), 404); + } + } +} diff --git a/backend/app/Http/Actions/SelfService/ResendOrderConfirmationPublicAction.php b/backend/app/Http/Actions/SelfService/ResendOrderConfirmationPublicAction.php new file mode 100644 index 0000000000..51e23d933a --- /dev/null +++ b/backend/app/Http/Actions/SelfService/ResendOrderConfirmationPublicAction.php @@ -0,0 +1,43 @@ +handler->handle(ResendEmailPublicDTO::from([ + 'eventId' => $eventId, + 'orderShortId' => $orderShortId, + 'attendeeShortId' => null, + 'ipAddress' => $this->getClientIp($request), + 'userAgent' => $request->userAgent(), + ])); + + return $this->jsonResponse([ + 'message' => __('Order confirmation resent successfully'), + ]); + } catch (SelfServiceDisabledException $e) { + return $this->errorResponse($e->getMessage(), $e->getCode()); + } catch (ResourceNotFoundException $e) { + return $this->errorResponse($e->getMessage(), 404); + } + } +} diff --git a/backend/app/Http/Request/EventSettings/UpdateEventSettingsRequest.php b/backend/app/Http/Request/EventSettings/UpdateEventSettingsRequest.php index 7a936d6049..78b78b7a3e 100644 --- a/backend/app/Http/Request/EventSettings/UpdateEventSettingsRequest.php +++ b/backend/app/Http/Request/EventSettings/UpdateEventSettingsRequest.php @@ -98,6 +98,9 @@ public function rules(): array 'homepage_theme_settings.background' => ['nullable', 'string', ...RulesHelper::HEX_COLOR], 'homepage_theme_settings.mode' => ['nullable', 'string', Rule::in(['light', 'dark'])], 'homepage_theme_settings.background_type' => ['nullable', 'string', Rule::in(HomepageBackgroundType::valuesArray())], + + // Self-service settings + 'allow_attendee_self_edit' => ['boolean'], ]; } diff --git a/backend/app/Http/Request/Organizer/Settings/PartialUpdateOrganizerSettingsRequest.php b/backend/app/Http/Request/Organizer/Settings/PartialUpdateOrganizerSettingsRequest.php index 6108bab508..4466966af2 100644 --- a/backend/app/Http/Request/Organizer/Settings/PartialUpdateOrganizerSettingsRequest.php +++ b/backend/app/Http/Request/Organizer/Settings/PartialUpdateOrganizerSettingsRequest.php @@ -18,6 +18,7 @@ public static function rules(): array 'default_attendee_details_collection_method' => ['sometimes', 'nullable', Rule::in(AttendeeDetailsCollectionMethod::valuesArray())], 'default_show_marketing_opt_in' => ['sometimes', 'nullable', 'boolean'], 'default_pass_platform_fee_to_buyer' => ['sometimes', 'nullable', 'boolean'], + 'default_allow_attendee_self_edit' => ['sometimes', 'nullable', 'boolean'], // Social handles 'facebook_handle' => ['sometimes', 'nullable', 'string', 'max:255'], diff --git a/backend/app/Http/Request/Report/GetOrganizerReportRequest.php b/backend/app/Http/Request/Report/GetOrganizerReportRequest.php index 06ce240a7d..afbd35f4e8 100644 --- a/backend/app/Http/Request/Report/GetOrganizerReportRequest.php +++ b/backend/app/Http/Request/Report/GetOrganizerReportRequest.php @@ -12,6 +12,9 @@ public function rules(): array 'start_date' => 'date|before:end_date|required_with:end_date|nullable', 'end_date' => 'date|after:start_date|required_with:start_date|nullable', 'currency' => 'string|size:3|nullable', + 'event_id' => 'integer|nullable', + 'page' => 'integer|min:1|nullable', + 'per_page' => 'integer|min:1|max:1000|nullable', ]; } } diff --git a/backend/app/Http/Request/SelfService/EditAttendeePublicRequest.php b/backend/app/Http/Request/SelfService/EditAttendeePublicRequest.php new file mode 100644 index 0000000000..caad3405cf --- /dev/null +++ b/backend/app/Http/Request/SelfService/EditAttendeePublicRequest.php @@ -0,0 +1,29 @@ + ['sometimes', 'string', 'max:255'], + 'last_name' => ['sometimes', 'string', 'max:255'], + 'email' => ['sometimes', 'email', 'max:255'], + ]; + } + + public function messages(): array + { + return [ + 'first_name.string' => __('First name must be a string'), + 'first_name.max' => __('First name must not exceed 255 characters'), + 'last_name.string' => __('Last name must be a string'), + 'last_name.max' => __('Last name must not exceed 255 characters'), + 'email.email' => __('Email must be a valid email address'), + 'email.max' => __('Email must not exceed 255 characters'), + ]; + } +} diff --git a/backend/app/Http/Request/SelfService/EditOrderPublicRequest.php b/backend/app/Http/Request/SelfService/EditOrderPublicRequest.php new file mode 100644 index 0000000000..5752d41d7c --- /dev/null +++ b/backend/app/Http/Request/SelfService/EditOrderPublicRequest.php @@ -0,0 +1,29 @@ + ['sometimes', 'string', 'max:255'], + 'last_name' => ['sometimes', 'string', 'max:255'], + 'email' => ['sometimes', 'email', 'max:255'], + ]; + } + + public function messages(): array + { + return [ + 'first_name.string' => __('First name must be a string'), + 'first_name.max' => __('First name must not exceed 255 characters'), + 'last_name.string' => __('Last name must be a string'), + 'last_name.max' => __('Last name must not exceed 255 characters'), + 'email.email' => __('Email must be a valid email address'), + 'email.max' => __('Email must not exceed 255 characters'), + ]; + } +} diff --git a/backend/app/Mail/Attendee/AttendeeDetailsChangedMail.php b/backend/app/Mail/Attendee/AttendeeDetailsChangedMail.php new file mode 100644 index 0000000000..2f09708d32 --- /dev/null +++ b/backend/app/Mail/Attendee/AttendeeDetailsChangedMail.php @@ -0,0 +1,48 @@ +eventSettings->getSupportEmail(), + subject: __('Your Ticket Details Have Been Changed'), + ); + } + + public function content(): Content + { + return new Content( + markdown: 'emails.orders.attendee-details-changed', + with: [ + 'ticketTitle' => $this->ticketTitle, + 'event' => $this->event, + 'organizer' => $this->organizer, + 'eventSettings' => $this->eventSettings, + 'changedFields' => $this->changedFields, + ] + ); + } +} diff --git a/backend/app/Mail/Order/OrderDetailsChangedMail.php b/backend/app/Mail/Order/OrderDetailsChangedMail.php new file mode 100644 index 0000000000..5d1a8061a0 --- /dev/null +++ b/backend/app/Mail/Order/OrderDetailsChangedMail.php @@ -0,0 +1,46 @@ +eventSettings->getSupportEmail(), + subject: __('Your Order Details Have Been Changed'), + ); + } + + public function content(): Content + { + return new Content( + markdown: 'emails.orders.order-details-changed', + with: [ + 'event' => $this->event, + 'organizer' => $this->organizer, + 'eventSettings' => $this->eventSettings, + 'changedFields' => $this->changedFields, + ] + ); + } +} diff --git a/backend/app/Models/OrderAuditLog.php b/backend/app/Models/OrderAuditLog.php new file mode 100644 index 0000000000..9419cd5ff2 --- /dev/null +++ b/backend/app/Models/OrderAuditLog.php @@ -0,0 +1,33 @@ +belongsTo(Event::class); + } + + public function order(): BelongsTo + { + return $this->belongsTo(Order::class); + } + + public function attendee(): BelongsTo + { + return $this->belongsTo(Attendee::class); + } + + protected function getCastMap(): array + { + return [ + 'old_values' => 'array', + 'new_values' => 'array', + ]; + } +} diff --git a/backend/app/Models/StripePayment.php b/backend/app/Models/StripePayment.php index 4428e70332..cadd3a459e 100644 --- a/backend/app/Models/StripePayment.php +++ b/backend/app/Models/StripePayment.php @@ -11,7 +11,7 @@ class StripePayment extends BaseModel protected function getTimestampsEnabled(): bool { - return false; + return true; } protected function getCastMap(): array diff --git a/backend/app/Providers/RepositoryServiceProvider.php b/backend/app/Providers/RepositoryServiceProvider.php index 43e8b3b1f3..1dd82a1011 100644 --- a/backend/app/Providers/RepositoryServiceProvider.php +++ b/backend/app/Providers/RepositoryServiceProvider.php @@ -24,6 +24,7 @@ use HiEvents\Repository\Eloquent\InvoiceRepository; use HiEvents\Repository\Eloquent\MessageRepository; use HiEvents\Repository\Eloquent\OrderApplicationFeeRepository; +use HiEvents\Repository\Eloquent\OrderAuditLogRepository; use HiEvents\Repository\Eloquent\OrderItemRepository; use HiEvents\Repository\Eloquent\OrderPaymentPlatformFeeRepository; use HiEvents\Repository\Eloquent\OrderRefundRepository; @@ -68,6 +69,7 @@ use HiEvents\Repository\Interfaces\InvoiceRepositoryInterface; use HiEvents\Repository\Interfaces\MessageRepositoryInterface; use HiEvents\Repository\Interfaces\OrderApplicationFeeRepositoryInterface; +use HiEvents\Repository\Interfaces\OrderAuditLogRepositoryInterface; use HiEvents\Repository\Interfaces\OrderItemRepositoryInterface; use HiEvents\Repository\Interfaces\OrderPaymentPlatformFeeRepositoryInterface; use HiEvents\Repository\Interfaces\OrderRefundRepositoryInterface; @@ -134,6 +136,7 @@ class RepositoryServiceProvider extends ServiceProvider WebhookRepositoryInterface::class => WebhookRepository::class, WebhookLogRepositoryInterface::class => WebhookLogRepository::class, OrderApplicationFeeRepositoryInterface::class => OrderApplicationFeeRepository::class, + OrderAuditLogRepositoryInterface::class => OrderAuditLogRepository::class, OrderPaymentPlatformFeeRepositoryInterface::class => OrderPaymentPlatformFeeRepository::class, StripePayoutsRepositoryInterface::class => StripePayoutsRepository::class, AccountConfigurationRepositoryInterface::class => AccountConfigurationRepository::class, diff --git a/backend/app/Providers/RouteServiceProvider.php b/backend/app/Providers/RouteServiceProvider.php index 07606be9b2..2133f283b7 100644 --- a/backend/app/Providers/RouteServiceProvider.php +++ b/backend/app/Providers/RouteServiceProvider.php @@ -29,6 +29,14 @@ public function boot(): void ->by($request->user()?->id ?: $request->ip()); }); + RateLimiter::for('self-service-email', function (Request $request) { + return Limit::perHour(20)->by($request->route('order_short_id') ?? $request->ip()); + }); + + RateLimiter::for('self-service-edit', function (Request $request) { + return Limit::perHour(20)->by($request->route('order_short_id') ?? $request->ip()); + }); + $this->routes(function () { Route::middleware('api') ->group(base_path('routes/api.php')); diff --git a/backend/app/Repository/Eloquent/OrderAuditLogRepository.php b/backend/app/Repository/Eloquent/OrderAuditLogRepository.php new file mode 100644 index 0000000000..3b1f14599b --- /dev/null +++ b/backend/app/Repository/Eloquent/OrderAuditLogRepository.php @@ -0,0 +1,22 @@ + + */ +interface OrderAuditLogRepositoryInterface extends RepositoryInterface +{ + +} diff --git a/backend/app/Resources/Event/EventSettingsResource.php b/backend/app/Resources/Event/EventSettingsResource.php index 81383392d3..2d933583c7 100644 --- a/backend/app/Resources/Event/EventSettingsResource.php +++ b/backend/app/Resources/Event/EventSettingsResource.php @@ -76,6 +76,9 @@ public function toArray($request): array // Homepage theme settings 'homepage_theme_settings' => $this->getHomepageThemeSettings(), + + // Self-service settings + 'allow_attendee_self_edit' => $this->getAllowAttendeeSelfEdit(), ]; } } diff --git a/backend/app/Resources/Event/EventSettingsResourcePublic.php b/backend/app/Resources/Event/EventSettingsResourcePublic.php index 2cf8a1b4be..b6e0e1a975 100644 --- a/backend/app/Resources/Event/EventSettingsResourcePublic.php +++ b/backend/app/Resources/Event/EventSettingsResourcePublic.php @@ -82,6 +82,9 @@ public function toArray($request): array // Homepage theme settings 'homepage_theme_settings' => $this->getHomepageThemeSettings(), + + // Self-service settings + 'allow_attendee_self_edit' => $this->getAllowAttendeeSelfEdit(), ]; } } diff --git a/backend/app/Resources/Organizer/OrganizerSettingsResource.php b/backend/app/Resources/Organizer/OrganizerSettingsResource.php index dfa2f3692e..c17ee47cf2 100644 --- a/backend/app/Resources/Organizer/OrganizerSettingsResource.php +++ b/backend/app/Resources/Organizer/OrganizerSettingsResource.php @@ -18,6 +18,7 @@ public function toArray($request): array 'default_attendee_details_collection_method' => $this->getDefaultAttendeeDetailsCollectionMethod(), 'default_show_marketing_opt_in' => $this->getDefaultShowMarketingOptIn(), 'default_pass_platform_fee_to_buyer' => $this->getDefaultPassPlatformFeeToBuyer(), + 'default_allow_attendee_self_edit' => $this->getDefaultAllowAttendeeSelfEdit(), 'social_media_handles' => $this->getSocialMediaHandles(), 'homepage_theme_settings' => $this->getHomepageThemeSettings(), 'homepage_visibility' => $this->getHomepageVisibility(), diff --git a/backend/app/Services/Application/Handlers/EventSettings/DTO/UpdateEventSettingsDTO.php b/backend/app/Services/Application/Handlers/EventSettings/DTO/UpdateEventSettingsDTO.php index 37ee2472bc..3871b386c0 100644 --- a/backend/app/Services/Application/Handlers/EventSettings/DTO/UpdateEventSettingsDTO.php +++ b/backend/app/Services/Application/Handlers/EventSettings/DTO/UpdateEventSettingsDTO.php @@ -80,6 +80,9 @@ public function __construct( // Homepage theme settings public readonly ?array $homepage_theme_settings = null, + + // Self-service settings + public readonly bool $allow_attendee_self_edit = false, ) { } @@ -159,6 +162,9 @@ public static function createWithDefaults( 'mode' => 'light', 'background_type' => 'COLOR', ], + + // Self-service defaults + allow_attendee_self_edit: false, ); } } diff --git a/backend/app/Services/Application/Handlers/EventSettings/PartialUpdateEventSettingsHandler.php b/backend/app/Services/Application/Handlers/EventSettings/PartialUpdateEventSettingsHandler.php index 23493cfeed..fe2c7d0964 100644 --- a/backend/app/Services/Application/Handlers/EventSettings/PartialUpdateEventSettingsHandler.php +++ b/backend/app/Services/Application/Handlers/EventSettings/PartialUpdateEventSettingsHandler.php @@ -134,6 +134,9 @@ public function handle(PartialUpdateEventSettingsDTO $eventSettingsDTO): EventSe 'homepage_theme_settings' => array_key_exists('homepage_theme_settings', $eventSettingsDTO->settings) ? $eventSettingsDTO->settings['homepage_theme_settings'] : $existingSettings->getHomepageThemeSettings(), + + // Self-service settings + 'allow_attendee_self_edit' => $eventSettingsDTO->settings['allow_attendee_self_edit'] ?? $existingSettings->getAllowAttendeeSelfEdit(), ]), ); } diff --git a/backend/app/Services/Application/Handlers/EventSettings/UpdateEventSettingsHandler.php b/backend/app/Services/Application/Handlers/EventSettings/UpdateEventSettingsHandler.php index 1ee8e33a37..23568784a2 100644 --- a/backend/app/Services/Application/Handlers/EventSettings/UpdateEventSettingsHandler.php +++ b/backend/app/Services/Application/Handlers/EventSettings/UpdateEventSettingsHandler.php @@ -79,7 +79,7 @@ public function handle(UpdateEventSettingsDTO $settings): EventSettingDomainObje 'invoice_tax_details' => $this->purifier->purify($settings->invoice_tax_details), 'invoice_notes' => $this->purifier->purify($settings->invoice_notes), 'invoice_payment_terms_days' => $settings->invoice_payment_terms_days, - + // Ticket design settings 'ticket_design_settings' => $settings->ticket_design_settings, @@ -91,6 +91,9 @@ public function handle(UpdateEventSettingsDTO $settings): EventSettingDomainObje // Homepage theme settings 'homepage_theme_settings' => $settings->homepage_theme_settings, + + // Self-service settings + 'allow_attendee_self_edit' => $settings->allow_attendee_self_edit ], where: [ 'event_id' => $settings->event_id, diff --git a/backend/app/Services/Application/Handlers/Organizer/DTO/PartialUpdateOrganizerSettingsDTO.php b/backend/app/Services/Application/Handlers/Organizer/DTO/PartialUpdateOrganizerSettingsDTO.php index cb03edffe6..6a19b734d1 100644 --- a/backend/app/Services/Application/Handlers/Organizer/DTO/PartialUpdateOrganizerSettingsDTO.php +++ b/backend/app/Services/Application/Handlers/Organizer/DTO/PartialUpdateOrganizerSettingsDTO.php @@ -24,6 +24,7 @@ public function __construct( public readonly AttendeeDetailsCollectionMethod|Optional|null $defaultAttendeeDetailsCollectionMethod, public readonly bool|Optional|null $defaultShowMarketingOptIn, public readonly bool|Optional|null $defaultPassPlatformFeeToBuyer, + public readonly bool|Optional|null $defaultAllowAttendeeSelfEdit, // Social public readonly string|Optional|null $facebookHandle, diff --git a/backend/app/Services/Application/Handlers/Reports/DTO/GetOrganizerReportDTO.php b/backend/app/Services/Application/Handlers/Reports/DTO/GetOrganizerReportDTO.php index e66994b782..d623fcaa40 100644 --- a/backend/app/Services/Application/Handlers/Reports/DTO/GetOrganizerReportDTO.php +++ b/backend/app/Services/Application/Handlers/Reports/DTO/GetOrganizerReportDTO.php @@ -13,6 +13,9 @@ public function __construct( public readonly ?string $startDate, public readonly ?string $endDate, public readonly ?string $currency, + public readonly ?int $eventId = null, + public readonly int $page = 1, + public readonly int $perPage = 1000, ) { } diff --git a/backend/app/Services/Application/Handlers/Reports/GetOrganizerReportHandler.php b/backend/app/Services/Application/Handlers/Reports/GetOrganizerReportHandler.php index 751948c919..89f4cdf051 100644 --- a/backend/app/Services/Application/Handlers/Reports/GetOrganizerReportHandler.php +++ b/backend/app/Services/Application/Handlers/Reports/GetOrganizerReportHandler.php @@ -3,7 +3,9 @@ namespace HiEvents\Services\Application\Handlers\Reports; use HiEvents\Services\Application\Handlers\Reports\DTO\GetOrganizerReportDTO; +use HiEvents\Services\Domain\Report\DTO\PaginatedReportDTO; use HiEvents\Services\Domain\Report\Factory\OrganizerReportServiceFactory; +use HiEvents\Services\Domain\Report\OrganizerReports\PlatformFeesReport; use Illuminate\Support\Carbon; use Illuminate\Support\Collection; @@ -15,15 +17,27 @@ public function __construct( { } - public function handle(GetOrganizerReportDTO $reportData): Collection + public function handle(GetOrganizerReportDTO $reportData): Collection|PaginatedReportDTO { - return $this->reportServiceFactory - ->create($reportData->reportType) - ->generateReport( + $reportService = $this->reportServiceFactory->create($reportData->reportType); + + if ($reportService instanceof PlatformFeesReport) { + return $reportService->generateReport( organizerId: $reportData->organizerId, currency: $reportData->currency, startDate: $reportData->startDate ? Carbon::parse($reportData->startDate) : null, endDate: $reportData->endDate ? Carbon::parse($reportData->endDate) : null, + eventId: $reportData->eventId, + page: $reportData->page, + perPage: $reportData->perPage, ); + } + + return $reportService->generateReport( + organizerId: $reportData->organizerId, + currency: $reportData->currency, + startDate: $reportData->startDate ? Carbon::parse($reportData->startDate) : null, + endDate: $reportData->endDate ? Carbon::parse($reportData->endDate) : null, + ); } } diff --git a/backend/app/Services/Application/Handlers/SelfService/DTO/EditAttendeePublicDTO.php b/backend/app/Services/Application/Handlers/SelfService/DTO/EditAttendeePublicDTO.php new file mode 100644 index 0000000000..525bba9173 --- /dev/null +++ b/backend/app/Services/Application/Handlers/SelfService/DTO/EditAttendeePublicDTO.php @@ -0,0 +1,20 @@ +loadAndValidateEvent($dto->eventId); + $order = $this->loadAndValidateOrder($dto->orderShortId, $dto->eventId); + + $attendee = $this->attendeeRepository->findFirstWhere([ + AttendeeDomainObjectAbstract::SHORT_ID => $dto->attendeeShortId, + AttendeeDomainObjectAbstract::ORDER_ID => $order->getId(), + AttendeeDomainObjectAbstract::EVENT_ID => $dto->eventId, + ]); + + if (!$attendee) { + throw new ResourceNotFoundException(__('Attendee not found')); + } + + return $this->selfServiceEditAttendeeService->editAttendee( + attendee: $attendee, + firstName: $dto->firstName, + lastName: $dto->lastName, + email: $dto->email, + ipAddress: $dto->ipAddress, + userAgent: $dto->userAgent + ); + } +} diff --git a/backend/app/Services/Application/Handlers/SelfService/EditOrderPublicHandler.php b/backend/app/Services/Application/Handlers/SelfService/EditOrderPublicHandler.php new file mode 100644 index 0000000000..7c8e9d4654 --- /dev/null +++ b/backend/app/Services/Application/Handlers/SelfService/EditOrderPublicHandler.php @@ -0,0 +1,36 @@ +loadAndValidateEvent($dto->eventId); + $order = $this->loadAndValidateOrder($dto->orderShortId, $dto->eventId); + + return $this->selfServiceEditOrderService->editOrder( + order: $order, + firstName: $dto->firstName, + lastName: $dto->lastName, + email: $dto->email, + ipAddress: $dto->ipAddress, + userAgent: $dto->userAgent + ); + } +} diff --git a/backend/app/Services/Application/Handlers/SelfService/ResendAttendeeTicketPublicHandler.php b/backend/app/Services/Application/Handlers/SelfService/ResendAttendeeTicketPublicHandler.php new file mode 100644 index 0000000000..e6a1da06e9 --- /dev/null +++ b/backend/app/Services/Application/Handlers/SelfService/ResendAttendeeTicketPublicHandler.php @@ -0,0 +1,52 @@ +loadAndValidateEvent($dto->eventId); + $order = $this->loadAndValidateOrder($dto->orderShortId, $dto->eventId); + + if (!$dto->attendeeShortId) { + throw new ResourceNotFoundException(__('Attendee not found')); + } + + $attendee = $this->attendeeRepository->findFirstWhere([ + AttendeeDomainObjectAbstract::SHORT_ID => $dto->attendeeShortId, + AttendeeDomainObjectAbstract::ORDER_ID => $order->getId(), + AttendeeDomainObjectAbstract::EVENT_ID => $dto->eventId, + ]); + + if (!$attendee) { + throw new ResourceNotFoundException(__('Attendee not found')); + } + + $this->selfServiceResendEmailService->resendAttendeeTicket( + attendeeId: $attendee->getId(), + orderId: $order->getId(), + eventId: $dto->eventId, + ipAddress: $dto->ipAddress, + userAgent: $dto->userAgent + ); + } +} diff --git a/backend/app/Services/Application/Handlers/SelfService/ResendOrderConfirmationPublicHandler.php b/backend/app/Services/Application/Handlers/SelfService/ResendOrderConfirmationPublicHandler.php new file mode 100644 index 0000000000..f755dcc0cf --- /dev/null +++ b/backend/app/Services/Application/Handlers/SelfService/ResendOrderConfirmationPublicHandler.php @@ -0,0 +1,33 @@ +loadAndValidateEvent($dto->eventId); + $order = $this->loadAndValidateOrder($dto->orderShortId, $dto->eventId); + + $this->selfServiceResendEmailService->resendOrderConfirmation( + orderId: $order->getId(), + eventId: $dto->eventId, + ipAddress: $dto->ipAddress, + userAgent: $dto->userAgent + ); + } +} diff --git a/backend/app/Services/Application/Handlers/SelfService/SelfServiceValidationTrait.php b/backend/app/Services/Application/Handlers/SelfService/SelfServiceValidationTrait.php new file mode 100644 index 0000000000..d430492028 --- /dev/null +++ b/backend/app/Services/Application/Handlers/SelfService/SelfServiceValidationTrait.php @@ -0,0 +1,48 @@ +getEventSettings()?->getAllowAttendeeSelfEdit()) { + throw new SelfServiceDisabledException(); + } + } + + /** + * @throws SelfServiceDisabledException + */ + private function loadAndValidateEvent(int $eventId): EventDomainObject + { + $event = $this->eventRepository + ->loadRelation(EventSettingDomainObject::class) + ->findById($eventId); + + if (!$event) { + throw new ResourceNotFoundException(__('Event not found')); + } + + $this->validateSelfServiceEnabled($event); + + return $event; + } + + private function loadAndValidateOrder(string $orderShortId, int $eventId): OrderDomainObject + { + $order = $this->orderRepository->findByShortId($orderShortId); + + if (!$order || $order->getEventId() !== $eventId) { + throw new ResourceNotFoundException(__('Order not found')); + } + + return $order; + } +} diff --git a/backend/app/Services/Domain/Event/CreateEventService.php b/backend/app/Services/Domain/Event/CreateEventService.php index 94ea070c75..6262fb5b87 100644 --- a/backend/app/Services/Domain/Event/CreateEventService.php +++ b/backend/app/Services/Domain/Event/CreateEventService.php @@ -223,6 +223,7 @@ private function createEventSettings( 'attendee_details_collection_method' => $organizerSettings->getDefaultAttendeeDetailsCollectionMethod(), 'show_marketing_opt_in' => $organizerSettings->getDefaultShowMarketingOptIn(), 'pass_platform_fee_to_buyer' => $organizerSettings->getDefaultPassPlatformFeeToBuyer(), + 'allow_attendee_self_edit' => $organizerSettings->getDefaultAllowAttendeeSelfEdit() ?? false, 'ticket_design_settings' => [ 'accent_color' => $homepageThemeSettings['accent'] ?? '#333', ], diff --git a/backend/app/Services/Domain/Report/AbstractOrganizerReportService.php b/backend/app/Services/Domain/Report/AbstractOrganizerReportService.php index 2595ae0390..102dd3c57f 100644 --- a/backend/app/Services/Domain/Report/AbstractOrganizerReportService.php +++ b/backend/app/Services/Domain/Report/AbstractOrganizerReportService.php @@ -24,7 +24,7 @@ public function generateReport( int $organizerId, ?string $currency = null, ?Carbon $startDate = null, - ?Carbon $endDate = null + ?Carbon $endDate = null, ): Collection { $organizer = $this->organizerRepository->findById($organizerId); diff --git a/backend/app/Services/Domain/Report/DTO/PaginatedReportDTO.php b/backend/app/Services/Domain/Report/DTO/PaginatedReportDTO.php new file mode 100644 index 0000000000..dde8f813af --- /dev/null +++ b/backend/app/Services/Domain/Report/DTO/PaginatedReportDTO.php @@ -0,0 +1,32 @@ + $this->data->toArray(), + 'pagination' => [ + 'total' => $this->total, + 'page' => $this->page, + 'per_page' => $this->perPage, + 'last_page' => $this->lastPage, + ], + ]; + } +} diff --git a/backend/app/Services/Domain/Report/Factory/OrganizerReportServiceFactory.php b/backend/app/Services/Domain/Report/Factory/OrganizerReportServiceFactory.php index bd9119910d..b9a1cbe665 100644 --- a/backend/app/Services/Domain/Report/Factory/OrganizerReportServiceFactory.php +++ b/backend/app/Services/Domain/Report/Factory/OrganizerReportServiceFactory.php @@ -6,19 +6,21 @@ use HiEvents\Services\Domain\Report\AbstractOrganizerReportService; use HiEvents\Services\Domain\Report\OrganizerReports\CheckInSummaryReport; use HiEvents\Services\Domain\Report\OrganizerReports\EventsPerformanceReport; +use HiEvents\Services\Domain\Report\OrganizerReports\PlatformFeesReport; use HiEvents\Services\Domain\Report\OrganizerReports\RevenueSummaryReport; use HiEvents\Services\Domain\Report\OrganizerReports\TaxSummaryReport; use Illuminate\Support\Facades\App; class OrganizerReportServiceFactory { - public function create(OrganizerReportTypes $reportType): AbstractOrganizerReportService + public function create(OrganizerReportTypes $reportType): AbstractOrganizerReportService|PlatformFeesReport { return match ($reportType) { OrganizerReportTypes::REVENUE_SUMMARY => App::make(RevenueSummaryReport::class), OrganizerReportTypes::EVENTS_PERFORMANCE => App::make(EventsPerformanceReport::class), OrganizerReportTypes::TAX_SUMMARY => App::make(TaxSummaryReport::class), OrganizerReportTypes::CHECK_IN_SUMMARY => App::make(CheckInSummaryReport::class), + OrganizerReportTypes::PLATFORM_FEES => App::make(PlatformFeesReport::class), }; } } diff --git a/backend/app/Services/Domain/Report/OrganizerReports/PlatformFeesReport.php b/backend/app/Services/Domain/Report/OrganizerReports/PlatformFeesReport.php new file mode 100644 index 0000000000..1ed23edb50 --- /dev/null +++ b/backend/app/Services/Domain/Report/OrganizerReports/PlatformFeesReport.php @@ -0,0 +1,196 @@ +organizerRepository->findById($organizerId); + $timezone = $organizer->getTimezone(); + + $endDate = $endDate + ? $endDate->copy()->setTimezone($timezone)->endOfDay() + : now($timezone)->endOfDay(); + $startDate = $startDate + ? $startDate->copy()->setTimezone($timezone)->startOfDay() + : $endDate->copy()->subDays(30)->startOfDay(); + + $cacheKey = $this->getCacheKeyWithEvent($organizerId, $currency, $startDate, $endDate, $eventId, $page, $perPage); + + $total = $this->cache->remember( + key: $cacheKey . '.count', + ttl: Carbon::now()->addSeconds(self::CACHE_TTL_SECONDS), + callback: fn() => $this->getCount($organizerId, $startDate, $endDate, $currency, $eventId) + ); + + $results = $this->cache->remember( + key: $cacheKey, + ttl: Carbon::now()->addSeconds(self::CACHE_TTL_SECONDS), + callback: fn() => $this->queryBuilder->select( + $this->buildSqlQuery($startDate, $endDate, $currency, $eventId, $page, $perPage), + [ + 'organizer_id' => $organizerId, + ] + ) + ); + + $data = collect($results)->map(function ($row) { + $currencyCode = strtoupper($row->currency ?? 'USD'); + $divisor = Currency::isZeroDecimalCurrency($currencyCode) ? 1 : 100; + + return (object) [ + 'event_name' => $row->event_name, + 'event_id' => $row->event_id, + 'payment_date' => $row->payment_date, + 'order_reference' => $row->order_reference, + 'order_id' => $row->order_id, + 'amount_paid' => Currency::round(($row->amount_received ?? 0) / $divisor), + 'fee_amount' => Currency::round(($row->application_fee_net ?? 0) / $divisor), + 'vat_rate' => $row->application_fee_vat_rate ?? 0, + 'vat_amount' => Currency::round(($row->application_fee_vat ?? 0) / $divisor), + 'total_fee' => Currency::round(($row->application_fee_gross ?? 0) / $divisor), + 'currency' => $currencyCode, + 'payment_intent_id' => $row->payment_intent_id, + ]; + }); + + return new PaginatedReportDTO( + data: $data, + total: $total, + page: $page, + perPage: $perPage, + lastPage: (int) ceil($total / $perPage), + ); + } + + private function getCount(int $organizerId, Carbon $startDate, Carbon $endDate, ?string $currency, ?int $eventId): int + { + $result = $this->queryBuilder->select( + $this->buildCountQuery($startDate, $endDate, $currency, $eventId), + ['organizer_id' => $organizerId] + ); + + return (int) ($result[0]->count ?? 0); + } + + private function buildCountQuery(Carbon $startDate, Carbon $endDate, ?string $currency, ?int $eventId): string + { + $startDateStr = $startDate->toDateString(); + $endDateStr = $endDate->toDateString(); + $completedStatus = OrderStatus::COMPLETED->name; + $refundedStatus = OrderRefundStatus::REFUNDED->name; + $currencyFilter = $this->buildCurrencyFilter('sp.currency', $currency); + $eventFilter = $this->buildEventFilter($eventId); + + return << 0 + AND sp.created_at >= '$startDateStr 00:00:00' + AND sp.created_at <= '$endDateStr 23:59:59' + $currencyFilter + $eventFilter +SQL; + } + + private function buildSqlQuery(Carbon $startDate, Carbon $endDate, ?string $currency, ?int $eventId, int $page = 1, int $perPage = 1000): string + { + $startDateStr = $startDate->toDateString(); + $endDateStr = $endDate->toDateString(); + $completedStatus = OrderStatus::COMPLETED->name; + $refundedStatus = OrderRefundStatus::REFUNDED->name; + $currencyFilter = $this->buildCurrencyFilter('sp.currency', $currency); + $eventFilter = $this->buildEventFilter($eventId); + $offset = ($page - 1) * $perPage; + + return << 0 + AND sp.created_at >= '$startDateStr 00:00:00' + AND sp.created_at <= '$endDateStr 23:59:59' + $currencyFilter + $eventFilter + ORDER BY sp.created_at DESC + LIMIT $perPage OFFSET $offset +SQL; + } + + private function buildEventFilter(?int $eventId): string + { + if ($eventId === null) { + return ''; + } + return "AND e.id = $eventId"; + } + + private function getCacheKeyWithEvent(int $organizerId, ?string $currency, ?Carbon $startDate, ?Carbon $endDate, ?int $eventId, int $page, int $perPage): string + { + return static::class . "$organizerId.$currency.{$startDate?->toDateString()}.{$endDate?->toDateString()}.$eventId.$page.$perPage"; + } + + private function buildCurrencyFilter(string $column, ?string $currency): string + { + if ($currency === null) { + return ''; + } + $escapedCurrency = addslashes($currency); + return "AND $column = '$escapedCurrency'"; + } +} diff --git a/backend/app/Services/Domain/SelfService/DTO/EditAttendeeResultDTO.php b/backend/app/Services/Domain/SelfService/DTO/EditAttendeeResultDTO.php new file mode 100644 index 0000000000..9402602614 --- /dev/null +++ b/backend/app/Services/Domain/SelfService/DTO/EditAttendeeResultDTO.php @@ -0,0 +1,13 @@ +orderAuditLogRepository->create([ + 'event_id' => $attendee->getEventId(), + 'order_id' => $attendee->getOrderId(), + 'attendee_id' => $attendee->getId(), + 'action' => OrderAuditAction::ATTENDEE_UPDATED->value, + 'old_values' => $oldValues, + 'new_values' => $newValues, + 'changed_fields' => implode(',', $changedFields), + 'ip_address' => $ipAddress, + 'user_agent' => $userAgent, + ]); + } + + public function logOrderUpdate( + OrderDomainObject $order, + array $oldValues, + array $newValues, + string $ipAddress, + ?string $userAgent + ): void { + $changedFields = array_keys($newValues); + + $this->orderAuditLogRepository->create([ + 'event_id' => $order->getEventId(), + 'order_id' => $order->getId(), + 'attendee_id' => null, + 'action' => OrderAuditAction::ORDER_UPDATED->value, + 'old_values' => $oldValues, + 'new_values' => $newValues, + 'changed_fields' => implode(',', $changedFields), + 'ip_address' => $ipAddress, + 'user_agent' => $userAgent, + ]); + } + + public function logEmailResent( + string $action, + int $eventId, + int $orderId, + ?int $attendeeId, + string $ipAddress, + ?string $userAgent + ): void { + $this->orderAuditLogRepository->create([ + 'event_id' => $eventId, + 'order_id' => $orderId, + 'attendee_id' => $attendeeId, + 'action' => $action, + 'old_values' => null, + 'new_values' => null, + 'changed_fields' => null, + 'ip_address' => $ipAddress, + 'user_agent' => $userAgent, + ]); + } +} diff --git a/backend/app/Services/Domain/SelfService/SelfServiceEditAttendeeService.php b/backend/app/Services/Domain/SelfService/SelfServiceEditAttendeeService.php new file mode 100644 index 0000000000..49bdf9a721 --- /dev/null +++ b/backend/app/Services/Domain/SelfService/SelfServiceEditAttendeeService.php @@ -0,0 +1,183 @@ +getFirstName()) { + $oldValues['first_name'] = $attendee->getFirstName(); + $newValues['first_name'] = $firstName; + $updateData['first_name'] = $firstName; + } + + if ($lastName !== null && $lastName !== $attendee->getLastName()) { + $oldValues['last_name'] = $attendee->getLastName(); + $newValues['last_name'] = $lastName; + $updateData['last_name'] = $lastName; + } + + if ($email !== null && $email !== $attendee->getEmail()) { + $oldValues['email'] = $attendee->getEmail(); + $newValues['email'] = $email; + $updateData['email'] = $email; + $emailChanged = true; + } + + if (!empty($updateData)) { + $oldEmail = $attendee->getEmail(); + + if ($emailChanged) { + $newShortId = IdHelper::shortId(IdHelper::ATTENDEE_PREFIX); + $updateData['short_id'] = $newShortId; + $shortIdChanged = true; + + $oldValues['short_id'] = $attendee->getShortId(); + $newValues['short_id'] = $newShortId; + } + + $this->attendeeRepository->updateWhere( + attributes: $updateData, + where: ['id' => $attendee->getId()] + ); + + $event = $this->loadEventWithRelations($attendee->getEventId()); + + if ($emailChanged) { + $this->sendTicketToNewEmail($attendee->getId(), $event); + } + + $this->sendChangeNotificationToOldEmail( + oldEmail: $oldEmail, + attendeeId: $attendee->getId(), + event: $event, + oldValues: $oldValues, + newValues: $newValues + ); + + $this->orderAuditLogService->logAttendeeUpdate( + attendee: $attendee, + oldValues: $oldValues, + newValues: $newValues, + ipAddress: $ipAddress, + userAgent: $userAgent + ); + } + + return new EditAttendeeResultDTO( + success: true, + shortIdChanged: $shortIdChanged, + newShortId: $newShortId, + emailChanged: $emailChanged + ); + } + + private function loadEventWithRelations(int $eventId): EventDomainObject + { + return $this->eventRepository + ->loadRelation(new Relationship(OrganizerDomainObject::class, name: 'organizer')) + ->loadRelation(EventSettingDomainObject::class) + ->findById($eventId); + } + + private function sendTicketToNewEmail(int $attendeeId, EventDomainObject $event): void + { + $attendee = $this->attendeeRepository + ->loadRelation(new Relationship(OrderDomainObject::class, nested: [ + new Relationship(OrderItemDomainObject::class), + ], name: 'order')) + ->findById($attendeeId); + + $this->sendAttendeeTicketService->send( + order: $attendee->getOrder(), + attendee: $attendee, + event: $event, + eventSettings: $event->getEventSettings(), + organizer: $event->getOrganizer(), + ); + } + + private function sendChangeNotificationToOldEmail( + string $oldEmail, + int $attendeeId, + EventDomainObject $event, + array $oldValues, + array $newValues + ): void { + $attendee = $this->attendeeRepository + ->loadRelation(new Relationship(ProductDomainObject::class, name: 'product')) + ->findById($attendeeId); + + $changedFields = $this->formatChangedFields($oldValues, $newValues); + + Mail::to($oldEmail)->queue(new AttendeeDetailsChangedMail( + ticketTitle: $attendee->getProduct()?->getTitle() ?? __('Ticket'), + event: $event, + organizer: $event->getOrganizer(), + eventSettings: $event->getEventSettings(), + changedFields: $changedFields + )); + } + + private function formatChangedFields(array $oldValues, array $newValues): array + { + $fieldLabels = [ + 'first_name' => __('First Name'), + 'last_name' => __('Last Name'), + 'email' => __('Email'), + 'short_id' => __('Ticket Reference'), + ]; + + $changedFields = []; + foreach ($oldValues as $field => $oldValue) { + if ($field === 'short_id') { + continue; + } + $label = $fieldLabels[$field] ?? $field; + $changedFields[$label] = [ + 'old' => $oldValue, + 'new' => $newValues[$field] ?? '', + ]; + } + + return $changedFields; + } +} diff --git a/backend/app/Services/Domain/SelfService/SelfServiceEditOrderService.php b/backend/app/Services/Domain/SelfService/SelfServiceEditOrderService.php new file mode 100644 index 0000000000..0fa7727414 --- /dev/null +++ b/backend/app/Services/Domain/SelfService/SelfServiceEditOrderService.php @@ -0,0 +1,176 @@ +getFirstName()) { + $oldValues['first_name'] = $order->getFirstName(); + $newValues['first_name'] = $firstName; + $updateData['first_name'] = $firstName; + } + + if ($lastName !== null && $lastName !== $order->getLastName()) { + $oldValues['last_name'] = $order->getLastName(); + $newValues['last_name'] = $lastName; + $updateData['last_name'] = $lastName; + } + + if ($email !== null && $email !== $order->getEmail()) { + $oldValues['email'] = $order->getEmail(); + $newValues['email'] = $email; + $updateData['email'] = $email; + $emailChanged = true; + } + + if (!empty($updateData)) { + $oldEmail = $order->getEmail(); + + if ($emailChanged) { + $newShortId = IdHelper::shortId(IdHelper::ORDER_PREFIX); + $updateData['short_id'] = $newShortId; + $shortIdChanged = true; + + $oldValues['short_id'] = $order->getShortId(); + $newValues['short_id'] = $newShortId; + } + + $this->orderRepository->updateWhere( + attributes: $updateData, + where: ['id' => $order->getId()] + ); + + $event = $this->loadEventWithRelations($order->getEventId()); + + if ($emailChanged) { + $this->sendConfirmationToNewEmail($order->getId(), $event); + } + + $this->sendChangeNotificationToOldEmail( + oldEmail: $oldEmail, + event: $event, + oldValues: $oldValues, + newValues: $newValues + ); + + $this->orderAuditLogService->logOrderUpdate( + order: $order, + oldValues: $oldValues, + newValues: $newValues, + ipAddress: $ipAddress, + userAgent: $userAgent + ); + } + + return new EditOrderResultDTO( + success: true, + shortIdChanged: $shortIdChanged, + newShortId: $newShortId, + emailChanged: $emailChanged + ); + } + + private function loadEventWithRelations(int $eventId): EventDomainObject + { + return $this->eventRepository + ->loadRelation(new Relationship(OrganizerDomainObject::class, name: 'organizer')) + ->loadRelation(new Relationship(EventSettingDomainObject::class)) + ->findById($eventId); + } + + private function sendConfirmationToNewEmail(int $orderId, EventDomainObject $event): void + { + $order = $this->orderRepository + ->loadRelation(OrderItemDomainObject::class) + ->loadRelation(AttendeeDomainObject::class) + ->loadRelation(InvoiceDomainObject::class) + ->findById($orderId); + + $this->sendOrderDetailsService->sendCustomerOrderSummary( + order: $order, + event: $event, + organizer: $event->getOrganizer(), + eventSettings: $event->getEventSettings(), + invoice: $order->getLatestInvoice() + ); + } + + private function sendChangeNotificationToOldEmail( + string $oldEmail, + EventDomainObject $event, + array $oldValues, + array $newValues + ): void { + $changedFields = $this->formatChangedFields($oldValues, $newValues); + + Mail::to($oldEmail)->queue(new OrderDetailsChangedMail( + event: $event, + organizer: $event->getOrganizer(), + eventSettings: $event->getEventSettings(), + changedFields: $changedFields + )); + } + + private function formatChangedFields(array $oldValues, array $newValues): array + { + $fieldLabels = [ + 'first_name' => __('First Name'), + 'last_name' => __('Last Name'), + 'email' => __('Email'), + 'short_id' => __('Order Reference'), + ]; + + $changedFields = []; + foreach ($oldValues as $field => $oldValue) { + if ($field === 'short_id') { + continue; + } + $label = $fieldLabels[$field] ?? $field; + $changedFields[$label] = [ + 'old' => $oldValue, + 'new' => $newValues[$field] ?? '', + ]; + } + + return $changedFields; + } +} diff --git a/backend/app/Services/Domain/SelfService/SelfServiceResendEmailService.php b/backend/app/Services/Domain/SelfService/SelfServiceResendEmailService.php new file mode 100644 index 0000000000..bf0d08142b --- /dev/null +++ b/backend/app/Services/Domain/SelfService/SelfServiceResendEmailService.php @@ -0,0 +1,108 @@ +attendeeRepository + ->loadRelation(new Relationship(OrderDomainObject::class, nested: [ + new Relationship(OrderItemDomainObject::class), + ], name: 'order')) + ->findFirstWhere([ + 'id' => $attendeeId, + 'order_id' => $orderId, + 'event_id' => $eventId, + ]); + + $event = $this->eventRepository + ->loadRelation(new Relationship(OrganizerDomainObject::class, name: 'organizer')) + ->loadRelation(EventSettingDomainObject::class) + ->findById($eventId); + + $this->sendAttendeeTicketService->send( + order: $attendee->getOrder(), + attendee: $attendee, + event: $event, + eventSettings: $event->getEventSettings(), + organizer: $event->getOrganizer(), + ); + + $this->orderAuditLogService->logEmailResent( + action: OrderAuditAction::ATTENDEE_EMAIL_RESENT->value, + eventId: $eventId, + orderId: $orderId, + attendeeId: $attendeeId, + ipAddress: $ipAddress, + userAgent: $userAgent + ); + } + + public function resendOrderConfirmation( + int $orderId, + int $eventId, + string $ipAddress, + ?string $userAgent + ): void { + $order = $this->orderRepository + ->loadRelation(OrderItemDomainObject::class) + ->loadRelation(AttendeeDomainObject::class) + ->loadRelation(InvoiceDomainObject::class) + ->findFirstWhere([ + 'id' => $orderId, + 'event_id' => $eventId, + ]); + + $event = $this->eventRepository + ->loadRelation(new Relationship(OrganizerDomainObject::class, name: 'organizer')) + ->loadRelation(new Relationship(EventSettingDomainObject::class)) + ->findById($eventId); + + $this->sendOrderDetailsService->sendCustomerOrderSummary( + order: $order, + event: $event, + organizer: $event->getOrganizer(), + eventSettings: $event->getEventSettings(), + invoice: $order->getLatestInvoice() + ); + + $this->orderAuditLogService->logEmailResent( + action: OrderAuditAction::ORDER_EMAIL_RESENT->value, + eventId: $eventId, + orderId: $orderId, + attendeeId: null, + ipAddress: $ipAddress, + userAgent: $userAgent + ); + } +} diff --git a/backend/database/migrations/2025_12_16_000000_create_order_audit_logs_table.php b/backend/database/migrations/2025_12_16_000000_create_order_audit_logs_table.php new file mode 100644 index 0000000000..4a550cb90f --- /dev/null +++ b/backend/database/migrations/2025_12_16_000000_create_order_audit_logs_table.php @@ -0,0 +1,41 @@ +id(); + $table->foreignId('event_id') + ->constrained('events') + ->onDelete('cascade'); + $table->foreignId('order_id') + ->constrained('orders') + ->onDelete('cascade'); + $table->foreignId('attendee_id') + ->nullable() + ->constrained('attendees') + ->onDelete('set null'); + $table->string('action', 50); + $table->json('old_values')->nullable(); + $table->json('new_values')->nullable(); + $table->string('changed_fields')->nullable(); + $table->string('ip_address', 45)->nullable(); + $table->string('user_agent')->nullable(); + $table->timestamps(); + + $table->index(['order_id', 'created_at']); + $table->index(['event_id', 'created_at']); + $table->index(['attendee_id']); + }); + } + + public function down(): void + { + Schema::dropIfExists('order_audit_logs'); + } +}; diff --git a/backend/database/migrations/2025_12_16_000001_add_allow_attendee_self_edit_to_event_settings.php b/backend/database/migrations/2025_12_16_000001_add_allow_attendee_self_edit_to_event_settings.php new file mode 100644 index 0000000000..8e1c670a9d --- /dev/null +++ b/backend/database/migrations/2025_12_16_000001_add_allow_attendee_self_edit_to_event_settings.php @@ -0,0 +1,22 @@ +boolean('allow_attendee_self_edit')->default(true)->after('show_marketing_opt_in'); + }); + } + + public function down(): void + { + Schema::table('event_settings', function (Blueprint $table) { + $table->dropColumn('allow_attendee_self_edit'); + }); + } +}; diff --git a/backend/database/migrations/2025_12_16_000002_add_default_allow_attendee_self_edit_to_organizer_settings.php b/backend/database/migrations/2025_12_16_000002_add_default_allow_attendee_self_edit_to_organizer_settings.php new file mode 100644 index 0000000000..af2e8aa431 --- /dev/null +++ b/backend/database/migrations/2025_12_16_000002_add_default_allow_attendee_self_edit_to_organizer_settings.php @@ -0,0 +1,22 @@ +boolean('default_allow_attendee_self_edit')->default(true)->after('default_pass_platform_fee_to_buyer'); + }); + } + + public function down(): void + { + Schema::table('organizer_settings', function (Blueprint $table) { + $table->dropColumn('default_allow_attendee_self_edit'); + }); + } +}; diff --git a/backend/database/migrations/2025_12_21_111333_backfill_stripe_payments_timestamps.php b/backend/database/migrations/2025_12_21_111333_backfill_stripe_payments_timestamps.php new file mode 100644 index 0000000000..4e493d9dc5 --- /dev/null +++ b/backend/database/migrations/2025_12_21_111333_backfill_stripe_payments_timestamps.php @@ -0,0 +1,24 @@ + +# {{ __('Ticket Details Changed') }} + +{{ __('The details on your ticket for **:eventName** have been updated.', ['eventName' => $event->getTitle()]) }} + +**{{ __('Ticket') }}**: {{ $ticketTitle }} + +## {{ __('What Changed') }} + +@foreach($changedFields as $field => $change) +- **{{ $field }}**: {{ $change['old'] }} → {{ $change['new'] }} +@endforeach + +{{ __('If you did not make this change, please contact the event organizer immediately.') }} + +--- + +{{ __('Event Organizer: :organizerName', ['organizerName' => $organizer->getName()]) }} + +@if($eventSettings->getSupportEmail()) +{{ __('Contact: :email', ['email' => $eventSettings->getSupportEmail()]) }} +@endif + +{{ __('Thanks,') }}
+{{ $organizer->getName() }} + diff --git a/backend/resources/views/emails/orders/order-details-changed.blade.php b/backend/resources/views/emails/orders/order-details-changed.blade.php new file mode 100644 index 0000000000..3ba9c56831 --- /dev/null +++ b/backend/resources/views/emails/orders/order-details-changed.blade.php @@ -0,0 +1,29 @@ +@php /** @var \HiEvents\DomainObjects\EventDomainObject $event */ @endphp +@php /** @var \HiEvents\DomainObjects\OrganizerDomainObject $organizer */ @endphp +@php /** @var \HiEvents\DomainObjects\EventSettingDomainObject $eventSettings */ @endphp +@php /** @var array $changedFields */ @endphp + + +# {{ __('Order Details Changed') }} + +{{ __('The details on your order for **:eventName** have been updated.', ['eventName' => $event->getTitle()]) }} + +## {{ __('What Changed') }} + +@foreach($changedFields as $field => $change) +- **{{ $field }}**: {{ $change['old'] }} → {{ $change['new'] }} +@endforeach + +{{ __('If you did not make this change, please contact the event organizer immediately.') }} + +--- + +{{ __('Event Organizer: :organizerName', ['organizerName' => $organizer->getName()]) }} + +@if($eventSettings->getSupportEmail()) +{{ __('Contact: :email', ['email' => $eventSettings->getSupportEmail()]) }} +@endif + +{{ __('Thanks,') }}
+{{ $organizer->getName() }} +
diff --git a/backend/routes/api.php b/backend/routes/api.php index 5d7b600920..7e451541c1 100644 --- a/backend/routes/api.php +++ b/backend/routes/api.php @@ -97,6 +97,10 @@ use HiEvents\Http\Actions\Orders\Public\TransitionOrderToOfflinePaymentPublicAction; use HiEvents\Http\Actions\Orders\ResendOrderConfirmationAction; use HiEvents\Http\Actions\Organizers\CreateOrganizerAction; +use HiEvents\Http\Actions\SelfService\EditAttendeePublicAction; +use HiEvents\Http\Actions\SelfService\EditOrderPublicAction; +use HiEvents\Http\Actions\SelfService\ResendAttendeeTicketPublicAction; +use HiEvents\Http\Actions\SelfService\ResendOrderConfirmationPublicAction; use HiEvents\Http\Actions\Organizers\EditOrganizerAction; use HiEvents\Http\Actions\Organizers\GetOrganizerAction; use HiEvents\Http\Actions\Organizers\GetOrganizerEventsAction; @@ -134,6 +138,7 @@ use HiEvents\Http\Actions\Questions\GetQuestionsAction; use HiEvents\Http\Actions\Questions\GetQuestionsPublicAction; use HiEvents\Http\Actions\Questions\SortQuestionsAction; +use HiEvents\Http\Actions\Reports\ExportOrganizerReportAction; use HiEvents\Http\Actions\Reports\GetOrganizerReportAction; use HiEvents\Http\Actions\Reports\GetReportAction; use HiEvents\Http\Actions\Sitemap\GetSitemapEventsAction; @@ -250,6 +255,7 @@ function (Router $router): void { $router->get('/organizers/{organizer_id}/settings', GetOrganizerSettingsAction::class); $router->patch('/organizers/{organizer_id}/settings', PartialUpdateOrganizerSettingsAction::class); $router->get('/organizers/{organizer_id}/reports/{report_type}', GetOrganizerReportAction::class); + $router->get('/organizers/{organizer_id}/reports/{report_type}/export', ExportOrganizerReportAction::class); // Email Templates - Organizer level $router->get('/organizers/{organizerId}/email-templates', GetOrganizerEmailTemplatesAction::class); @@ -467,6 +473,15 @@ function (Router $router): void { $router->post('/ticket-lookup', SendTicketLookupEmailAction::class); $router->get('/ticket-lookup/{token}', GetOrdersByLookupTokenAction::class); + // Self-service order and attendee edits + $router->prefix('/events/{event_id}/order/{order_short_id}')->group(function (Router $router): void { + $router->patch('/', EditOrderPublicAction::class)->middleware('throttle:self-service-edit'); + $router->post('/resend-confirmation', ResendOrderConfirmationPublicAction::class)->middleware('throttle:self-service-email'); + + $router->patch('/attendees/{attendee_short_id}', EditAttendeePublicAction::class)->middleware('throttle:self-service-edit'); + $router->post('/attendees/{attendee_short_id}/resend-ticket', ResendAttendeeTicketPublicAction::class)->middleware('throttle:self-service-email'); + }); + // Sitemap $router->get('/sitemap.xml', GetSitemapIndexAction::class); $router->get('/sitemap-events-{page}.xml', GetSitemapEventsAction::class)->where('page', '[0-9]+'); diff --git a/backend/tests/Unit/Services/Domain/SelfService/OrderAuditLogServiceTest.php b/backend/tests/Unit/Services/Domain/SelfService/OrderAuditLogServiceTest.php new file mode 100644 index 0000000000..1f907ce078 --- /dev/null +++ b/backend/tests/Unit/Services/Domain/SelfService/OrderAuditLogServiceTest.php @@ -0,0 +1,210 @@ +orderAuditLogRepository = Mockery::mock(OrderAuditLogRepositoryInterface::class); + + $this->service = new OrderAuditLogService( + $this->orderAuditLogRepository + ); + } + + public function testLogAttendeeUpdateCreatesAuditLogEntry(): void + { + $attendee = Mockery::mock(AttendeeDomainObject::class); + $attendee->shouldReceive('getEventId')->andReturn(1); + $attendee->shouldReceive('getOrderId')->andReturn(123); + $attendee->shouldReceive('getId')->andReturn(456); + + $oldValues = [ + 'first_name' => 'John', + 'email' => 'old@example.com', + ]; + + $newValues = [ + 'first_name' => 'Jane', + 'email' => 'new@example.com', + ]; + + $ipAddress = '192.168.1.1'; + $userAgent = 'Mozilla/5.0'; + + $auditLog = Mockery::mock(OrderAuditLogDomainObject::class); + + $this->orderAuditLogRepository + ->shouldReceive('create') + ->once() + ->withArgs(function ($data) use ($attendee, $oldValues, $newValues, $ipAddress, $userAgent) { + return $data['event_id'] === 1 + && $data['order_id'] === 123 + && $data['attendee_id'] === 456 + && $data['action'] === OrderAuditAction::ATTENDEE_UPDATED->value + && $data['old_values'] === $oldValues + && $data['new_values'] === $newValues + && $data['changed_fields'] === 'first_name,email' + && $data['ip_address'] === $ipAddress + && $data['user_agent'] === $userAgent; + }) + ->andReturn($auditLog); + + $this->service->logAttendeeUpdate( + attendee: $attendee, + oldValues: $oldValues, + newValues: $newValues, + ipAddress: $ipAddress, + userAgent: $userAgent + ); + + $this->assertTrue(true); + } + + public function testLogOrderUpdateCreatesAuditLogEntry(): void + { + $order = Mockery::mock(OrderDomainObject::class); + $order->shouldReceive('getEventId')->andReturn(1); + $order->shouldReceive('getId')->andReturn(123); + + $oldValues = [ + 'first_name' => 'John', + 'last_name' => 'Doe', + ]; + + $newValues = [ + 'first_name' => 'Jane', + 'last_name' => 'Smith', + ]; + + $ipAddress = '192.168.1.1'; + $userAgent = 'Mozilla/5.0'; + + $auditLog = Mockery::mock(OrderAuditLogDomainObject::class); + + $this->orderAuditLogRepository + ->shouldReceive('create') + ->once() + ->withArgs(function ($data) use ($order, $oldValues, $newValues, $ipAddress, $userAgent) { + return $data['event_id'] === 1 + && $data['order_id'] === 123 + && $data['attendee_id'] === null + && $data['action'] === OrderAuditAction::ORDER_UPDATED->value + && $data['old_values'] === $oldValues + && $data['new_values'] === $newValues + && $data['changed_fields'] === 'first_name,last_name' + && $data['ip_address'] === $ipAddress + && $data['user_agent'] === $userAgent; + }) + ->andReturn($auditLog); + + $this->service->logOrderUpdate( + order: $order, + oldValues: $oldValues, + newValues: $newValues, + ipAddress: $ipAddress, + userAgent: $userAgent + ); + + $this->assertTrue(true); + } + + public function testLogEmailResentForAttendee(): void + { + $action = OrderAuditAction::ATTENDEE_EMAIL_RESENT->value; + $eventId = 1; + $orderId = 123; + $attendeeId = 456; + $ipAddress = '192.168.1.1'; + $userAgent = 'Mozilla/5.0'; + + $auditLog = Mockery::mock(OrderAuditLogDomainObject::class); + + $this->orderAuditLogRepository + ->shouldReceive('create') + ->once() + ->withArgs(function ($data) use ($action, $eventId, $orderId, $attendeeId, $ipAddress, $userAgent) { + return $data['event_id'] === $eventId + && $data['order_id'] === $orderId + && $data['attendee_id'] === $attendeeId + && $data['action'] === $action + && $data['old_values'] === null + && $data['new_values'] === null + && $data['changed_fields'] === null + && $data['ip_address'] === $ipAddress + && $data['user_agent'] === $userAgent; + }) + ->andReturn($auditLog); + + $this->service->logEmailResent( + action: $action, + eventId: $eventId, + orderId: $orderId, + attendeeId: $attendeeId, + ipAddress: $ipAddress, + userAgent: $userAgent + ); + + $this->assertTrue(true); + } + + public function testLogEmailResentForOrder(): void + { + $action = OrderAuditAction::ORDER_EMAIL_RESENT->value; + $eventId = 1; + $orderId = 123; + $ipAddress = '192.168.1.1'; + $userAgent = 'Mozilla/5.0'; + + $auditLog = Mockery::mock(OrderAuditLogDomainObject::class); + + $this->orderAuditLogRepository + ->shouldReceive('create') + ->once() + ->withArgs(function ($data) use ($action, $eventId, $orderId, $ipAddress, $userAgent) { + return $data['event_id'] === $eventId + && $data['order_id'] === $orderId + && $data['attendee_id'] === null + && $data['action'] === $action + && $data['old_values'] === null + && $data['new_values'] === null + && $data['changed_fields'] === null + && $data['ip_address'] === $ipAddress + && $data['user_agent'] === $userAgent; + }) + ->andReturn($auditLog); + + $this->service->logEmailResent( + action: $action, + eventId: $eventId, + orderId: $orderId, + attendeeId: null, + ipAddress: $ipAddress, + userAgent: $userAgent + ); + + $this->assertTrue(true); + } + + protected function tearDown(): void + { + Mockery::close(); + parent::tearDown(); + } +} diff --git a/backend/tests/Unit/Services/Domain/SelfService/SelfServiceEditAttendeeServiceTest.php b/backend/tests/Unit/Services/Domain/SelfService/SelfServiceEditAttendeeServiceTest.php new file mode 100644 index 0000000000..6b9364c230 --- /dev/null +++ b/backend/tests/Unit/Services/Domain/SelfService/SelfServiceEditAttendeeServiceTest.php @@ -0,0 +1,356 @@ +attendeeRepository = Mockery::mock(AttendeeRepositoryInterface::class); + $this->eventRepository = Mockery::mock(EventRepositoryInterface::class); + $this->orderAuditLogService = Mockery::mock(OrderAuditLogService::class); + $this->sendAttendeeTicketService = Mockery::mock(SendAttendeeTicketService::class); + + $this->service = new SelfServiceEditAttendeeService( + $this->attendeeRepository, + $this->eventRepository, + $this->orderAuditLogService, + $this->sendAttendeeTicketService + ); + } + + public function testSuccessfulEditUpdatesAttendeeFields(): void + { + $attendee = Mockery::mock(AttendeeDomainObject::class); + $attendee->shouldReceive('getId')->andReturn(456); + $attendee->shouldReceive('getEventId')->andReturn(789); + $attendee->shouldReceive('getFirstName')->andReturn('John'); + $attendee->shouldReceive('getLastName')->andReturn('Doe'); + $attendee->shouldReceive('getEmail')->andReturn('old@example.com'); + + $this->attendeeRepository + ->shouldReceive('updateWhere') + ->once() + ->withArgs(function ($attributes, $where) { + return $attributes === [ + 'first_name' => 'Jane', + 'last_name' => 'Smith', + ] + && $where === ['id' => 456]; + }) + ->andReturn(1); + + $mockProduct = Mockery::mock(ProductDomainObject::class); + $mockProduct->shouldReceive('getTitle')->andReturn('General Admission'); + + $mockAttendeeWithProduct = Mockery::mock(AttendeeDomainObject::class); + $mockAttendeeWithProduct->shouldReceive('getProduct')->andReturn($mockProduct); + + $this->attendeeRepository + ->shouldReceive('loadRelation') + ->andReturnSelf(); + + $this->attendeeRepository + ->shouldReceive('findById') + ->with(456) + ->andReturn($mockAttendeeWithProduct); + + $mockEventSettings = Mockery::mock(EventSettingDomainObject::class); + $mockEventSettings->shouldReceive('getSupportEmail')->andReturn('support@example.com'); + $mockOrganizer = Mockery::mock(OrganizerDomainObject::class); + $mockEvent = Mockery::mock(EventDomainObject::class); + $mockEvent->shouldReceive('getEventSettings')->andReturn($mockEventSettings); + $mockEvent->shouldReceive('getOrganizer')->andReturn($mockOrganizer); + + $this->eventRepository + ->shouldReceive('loadRelation') + ->andReturnSelf(); + + $this->eventRepository + ->shouldReceive('findById') + ->with(789) + ->andReturn($mockEvent); + + $this->orderAuditLogService + ->shouldReceive('logAttendeeUpdate') + ->once() + ->withArgs(function ($att, $oldValues, $newValues, $ip, $ua) use ($attendee) { + return $att === $attendee + && $oldValues === ['first_name' => 'John', 'last_name' => 'Doe'] + && $newValues === ['first_name' => 'Jane', 'last_name' => 'Smith'] + && $ip === '192.168.1.1' + && $ua === 'Mozilla/5.0'; + }); + + $result = $this->service->editAttendee( + attendee: $attendee, + firstName: 'Jane', + lastName: 'Smith', + email: null, + ipAddress: '192.168.1.1', + userAgent: 'Mozilla/5.0' + ); + + $this->assertTrue($result->success); + $this->assertFalse($result->shortIdChanged); + $this->assertNull($result->newShortId); + $this->assertFalse($result->emailChanged); + + Mail::assertQueued(AttendeeDetailsChangedMail::class, function ($mail) { + return $mail->hasTo('old@example.com'); + }); + } + + public function testEmailChangeTriggersShortIdRotation(): void + { + $attendee = Mockery::mock(AttendeeDomainObject::class); + $attendee->shouldReceive('getId')->andReturn(456); + $attendee->shouldReceive('getEventId')->andReturn(789); + $attendee->shouldReceive('getFirstName')->andReturn('John'); + $attendee->shouldReceive('getLastName')->andReturn('Doe'); + $attendee->shouldReceive('getEmail')->andReturn('old@example.com'); + $attendee->shouldReceive('getShortId')->andReturn('a_oldshortid123'); + + $this->attendeeRepository + ->shouldReceive('updateWhere') + ->once() + ->withArgs(function ($attributes, $where) { + return isset($attributes['email']) + && $attributes['email'] === 'new@example.com' + && isset($attributes['short_id']) + && str_starts_with($attributes['short_id'], 'a_') + && $where === ['id' => 456]; + }) + ->andReturn(1); + + $mockOrder = Mockery::mock(OrderDomainObject::class); + $mockProduct = Mockery::mock(ProductDomainObject::class); + $mockProduct->shouldReceive('getTitle')->andReturn('General Admission'); + + $mockAttendeeWithRelations = Mockery::mock(AttendeeDomainObject::class); + $mockAttendeeWithRelations->shouldReceive('getOrder')->andReturn($mockOrder); + $mockAttendeeWithRelations->shouldReceive('getProduct')->andReturn($mockProduct); + + $this->attendeeRepository + ->shouldReceive('loadRelation') + ->andReturnSelf(); + + $this->attendeeRepository + ->shouldReceive('findById') + ->with(456) + ->andReturn($mockAttendeeWithRelations); + + $mockEventSettings = Mockery::mock(EventSettingDomainObject::class); + $mockEventSettings->shouldReceive('getSupportEmail')->andReturn('support@example.com'); + $mockOrganizer = Mockery::mock(OrganizerDomainObject::class); + $mockEvent = Mockery::mock(EventDomainObject::class); + $mockEvent->shouldReceive('getEventSettings')->andReturn($mockEventSettings); + $mockEvent->shouldReceive('getOrganizer')->andReturn($mockOrganizer); + + $this->eventRepository + ->shouldReceive('loadRelation') + ->andReturnSelf(); + + $this->eventRepository + ->shouldReceive('findById') + ->with(789) + ->andReturn($mockEvent); + + $this->sendAttendeeTicketService + ->shouldReceive('send') + ->once(); + + $this->orderAuditLogService + ->shouldReceive('logAttendeeUpdate') + ->once() + ->withArgs(function ($att, $oldValues, $newValues, $ip, $ua) use ($attendee) { + return $att === $attendee + && isset($oldValues['email']) && $oldValues['email'] === 'old@example.com' + && isset($oldValues['short_id']) + && isset($newValues['email']) && $newValues['email'] === 'new@example.com' + && isset($newValues['short_id']) && str_starts_with($newValues['short_id'], 'a_') + && $ip === '192.168.1.1' + && $ua === 'Mozilla/5.0'; + }); + + $result = $this->service->editAttendee( + attendee: $attendee, + firstName: null, + lastName: null, + email: 'new@example.com', + ipAddress: '192.168.1.1', + userAgent: 'Mozilla/5.0' + ); + + $this->assertTrue($result->success); + $this->assertTrue($result->shortIdChanged); + $this->assertNotNull($result->newShortId); + $this->assertStringStartsWith('a_', $result->newShortId); + $this->assertTrue($result->emailChanged); + + Mail::assertQueued(AttendeeDetailsChangedMail::class, function ($mail) { + return $mail->hasTo('old@example.com'); + }); + } + + public function testNoUpdateWhenNoFieldsChange(): void + { + $attendee = Mockery::mock(AttendeeDomainObject::class); + $attendee->shouldReceive('getId')->andReturn(456); + $attendee->shouldReceive('getFirstName')->andReturn('John'); + $attendee->shouldReceive('getLastName')->andReturn('Doe'); + $attendee->shouldReceive('getEmail')->andReturn('same@example.com'); + + $this->attendeeRepository->shouldReceive('updateWhere')->never(); + $this->orderAuditLogService->shouldReceive('logAttendeeUpdate')->never(); + + $result = $this->service->editAttendee( + attendee: $attendee, + firstName: 'John', + lastName: 'Doe', + email: 'same@example.com', + ipAddress: '192.168.1.1', + userAgent: 'Mozilla/5.0' + ); + + $this->assertTrue($result->success); + $this->assertFalse($result->shortIdChanged); + $this->assertNull($result->newShortId); + $this->assertFalse($result->emailChanged); + + Mail::assertNothingSent(); + } + + public function testMultipleFieldsUpdateTogether(): void + { + $attendee = Mockery::mock(AttendeeDomainObject::class); + $attendee->shouldReceive('getId')->andReturn(456); + $attendee->shouldReceive('getEventId')->andReturn(789); + $attendee->shouldReceive('getFirstName')->andReturn('John'); + $attendee->shouldReceive('getLastName')->andReturn('Doe'); + $attendee->shouldReceive('getEmail')->andReturn('old@example.com'); + $attendee->shouldReceive('getShortId')->andReturn('a_oldshortid123'); + + $this->attendeeRepository + ->shouldReceive('updateWhere') + ->once() + ->withArgs(function ($attributes, $where) { + return isset($attributes['first_name']) + && $attributes['first_name'] === 'Jane' + && isset($attributes['last_name']) + && $attributes['last_name'] === 'Smith' + && isset($attributes['email']) + && $attributes['email'] === 'new@example.com' + && isset($attributes['short_id']) + && str_starts_with($attributes['short_id'], 'a_') + && $where === ['id' => 456]; + }) + ->andReturn(1); + + $mockOrder = Mockery::mock(OrderDomainObject::class); + $mockProduct = Mockery::mock(ProductDomainObject::class); + $mockProduct->shouldReceive('getTitle')->andReturn('VIP Pass'); + + $mockAttendeeWithRelations = Mockery::mock(AttendeeDomainObject::class); + $mockAttendeeWithRelations->shouldReceive('getOrder')->andReturn($mockOrder); + $mockAttendeeWithRelations->shouldReceive('getProduct')->andReturn($mockProduct); + + $this->attendeeRepository + ->shouldReceive('loadRelation') + ->andReturnSelf(); + + $this->attendeeRepository + ->shouldReceive('findById') + ->with(456) + ->andReturn($mockAttendeeWithRelations); + + $mockEventSettings = Mockery::mock(EventSettingDomainObject::class); + $mockEventSettings->shouldReceive('getSupportEmail')->andReturn('support@example.com'); + $mockOrganizer = Mockery::mock(OrganizerDomainObject::class); + $mockEvent = Mockery::mock(EventDomainObject::class); + $mockEvent->shouldReceive('getEventSettings')->andReturn($mockEventSettings); + $mockEvent->shouldReceive('getOrganizer')->andReturn($mockOrganizer); + + $this->eventRepository + ->shouldReceive('loadRelation') + ->andReturnSelf(); + + $this->eventRepository + ->shouldReceive('findById') + ->with(789) + ->andReturn($mockEvent); + + $this->sendAttendeeTicketService + ->shouldReceive('send') + ->once(); + + $this->orderAuditLogService + ->shouldReceive('logAttendeeUpdate') + ->once() + ->withArgs(function ($att, $oldValues, $newValues, $ip, $ua) use ($attendee) { + return $att === $attendee + && $oldValues['first_name'] === 'John' + && $oldValues['last_name'] === 'Doe' + && $oldValues['email'] === 'old@example.com' + && isset($oldValues['short_id']) + && $newValues['first_name'] === 'Jane' + && $newValues['last_name'] === 'Smith' + && $newValues['email'] === 'new@example.com' + && isset($newValues['short_id']) && str_starts_with($newValues['short_id'], 'a_') + && $ip === '192.168.1.1' + && $ua === 'Mozilla/5.0'; + }); + + $result = $this->service->editAttendee( + attendee: $attendee, + firstName: 'Jane', + lastName: 'Smith', + email: 'new@example.com', + ipAddress: '192.168.1.1', + userAgent: 'Mozilla/5.0' + ); + + $this->assertTrue($result->success); + $this->assertTrue($result->shortIdChanged); + $this->assertNotNull($result->newShortId); + $this->assertStringStartsWith('a_', $result->newShortId); + $this->assertTrue($result->emailChanged); + + Mail::assertQueued(AttendeeDetailsChangedMail::class, function ($mail) { + return $mail->hasTo('old@example.com'); + }); + } + + protected function tearDown(): void + { + Mockery::close(); + parent::tearDown(); + } +} diff --git a/backend/tests/Unit/Services/Domain/SelfService/SelfServiceEditOrderServiceTest.php b/backend/tests/Unit/Services/Domain/SelfService/SelfServiceEditOrderServiceTest.php new file mode 100644 index 0000000000..be9619ab39 --- /dev/null +++ b/backend/tests/Unit/Services/Domain/SelfService/SelfServiceEditOrderServiceTest.php @@ -0,0 +1,449 @@ +orderRepository = Mockery::mock(OrderRepositoryInterface::class); + $this->eventRepository = Mockery::mock(EventRepositoryInterface::class); + $this->orderAuditLogService = Mockery::mock(OrderAuditLogService::class); + $this->sendOrderDetailsService = Mockery::mock(SendOrderDetailsService::class); + + $this->service = new SelfServiceEditOrderService( + $this->orderRepository, + $this->eventRepository, + $this->orderAuditLogService, + $this->sendOrderDetailsService + ); + } + + public function testSuccessfulEditUpdatesOrderFields(): void + { + $order = Mockery::mock(OrderDomainObject::class); + $order->shouldReceive('getId')->andReturn(123); + $order->shouldReceive('getEventId')->andReturn(789); + $order->shouldReceive('getFirstName')->andReturn('John'); + $order->shouldReceive('getLastName')->andReturn('Doe'); + $order->shouldReceive('getEmail')->andReturn('old@example.com'); + + $this->orderRepository + ->shouldReceive('updateWhere') + ->once() + ->withArgs(function ($attributes, $where) { + return $attributes === [ + 'first_name' => 'Jane', + 'last_name' => 'Smith', + ] + && $where === ['id' => 123]; + }) + ->andReturn(1); + + $mockEventSettings = Mockery::mock(EventSettingDomainObject::class); + $mockEventSettings->shouldReceive('getSupportEmail')->andReturn('support@example.com'); + $mockOrganizer = Mockery::mock(OrganizerDomainObject::class); + $mockEvent = Mockery::mock(EventDomainObject::class); + $mockEvent->shouldReceive('getEventSettings')->andReturn($mockEventSettings); + $mockEvent->shouldReceive('getOrganizer')->andReturn($mockOrganizer); + + $this->eventRepository + ->shouldReceive('loadRelation') + ->andReturnSelf(); + + $this->eventRepository + ->shouldReceive('findById') + ->with(789) + ->andReturn($mockEvent); + + $this->orderAuditLogService + ->shouldReceive('logOrderUpdate') + ->once() + ->withArgs(function ($ord, $oldValues, $newValues, $ip, $ua) use ($order) { + return $ord === $order + && $oldValues === ['first_name' => 'John', 'last_name' => 'Doe'] + && $newValues === ['first_name' => 'Jane', 'last_name' => 'Smith'] + && $ip === '192.168.1.1' + && $ua === 'Mozilla/5.0'; + }); + + $result = $this->service->editOrder( + order: $order, + firstName: 'Jane', + lastName: 'Smith', + email: null, + ipAddress: '192.168.1.1', + userAgent: 'Mozilla/5.0' + ); + + $this->assertTrue($result->success); + $this->assertFalse($result->shortIdChanged); + $this->assertNull($result->newShortId); + $this->assertFalse($result->emailChanged); + + Mail::assertQueued(OrderDetailsChangedMail::class, function ($mail) { + return $mail->hasTo('old@example.com'); + }); + } + + public function testEmailChangeTriggersShortIdRotation(): void + { + $order = Mockery::mock(OrderDomainObject::class); + $order->shouldReceive('getId')->andReturn(123); + $order->shouldReceive('getEventId')->andReturn(789); + $order->shouldReceive('getFirstName')->andReturn('John'); + $order->shouldReceive('getLastName')->andReturn('Doe'); + $order->shouldReceive('getEmail')->andReturn('old@example.com'); + $order->shouldReceive('getShortId')->andReturn('o_oldshortid123'); + + $this->orderRepository + ->shouldReceive('updateWhere') + ->once() + ->withArgs(function ($attributes, $where) { + return isset($attributes['email']) + && $attributes['email'] === 'new@example.com' + && isset($attributes['short_id']) + && str_starts_with($attributes['short_id'], 'o_') + && $where === ['id' => 123]; + }) + ->andReturn(1); + + $mockOrderWithRelations = Mockery::mock(OrderDomainObject::class); + $mockOrderWithRelations->shouldReceive('getLatestInvoice')->andReturn(null); + + $this->orderRepository + ->shouldReceive('loadRelation') + ->andReturnSelf(); + + $this->orderRepository + ->shouldReceive('findById') + ->with(123) + ->andReturn($mockOrderWithRelations); + + $mockEventSettings = Mockery::mock(EventSettingDomainObject::class); + $mockOrganizer = Mockery::mock(OrganizerDomainObject::class); + $mockEvent = Mockery::mock(EventDomainObject::class); + $mockEvent->shouldReceive('getEventSettings')->andReturn($mockEventSettings); + $mockEvent->shouldReceive('getOrganizer')->andReturn($mockOrganizer); + + $this->eventRepository + ->shouldReceive('loadRelation') + ->andReturnSelf(); + + $this->eventRepository + ->shouldReceive('findById') + ->with(789) + ->andReturn($mockEvent); + + $this->sendOrderDetailsService + ->shouldReceive('sendCustomerOrderSummary') + ->once(); + + $this->orderAuditLogService + ->shouldReceive('logOrderUpdate') + ->once() + ->withArgs(function ($ord, $oldValues, $newValues, $ip, $ua) use ($order) { + return $ord === $order + && isset($oldValues['email']) && $oldValues['email'] === 'old@example.com' + && isset($oldValues['short_id']) + && isset($newValues['email']) && $newValues['email'] === 'new@example.com' + && isset($newValues['short_id']) && str_starts_with($newValues['short_id'], 'o_') + && $ip === '192.168.1.1' + && $ua === 'Mozilla/5.0'; + }); + + $result = $this->service->editOrder( + order: $order, + firstName: null, + lastName: null, + email: 'new@example.com', + ipAddress: '192.168.1.1', + userAgent: 'Mozilla/5.0' + ); + + $this->assertTrue($result->success); + $this->assertTrue($result->shortIdChanged); + $this->assertNotNull($result->newShortId); + $this->assertStringStartsWith('o_', $result->newShortId); + $this->assertTrue($result->emailChanged); + } + + public function testNoUpdateWhenNoFieldsChange(): void + { + $order = Mockery::mock(OrderDomainObject::class); + $order->shouldReceive('getId')->andReturn(123); + $order->shouldReceive('getFirstName')->andReturn('John'); + $order->shouldReceive('getLastName')->andReturn('Doe'); + $order->shouldReceive('getEmail')->andReturn('same@example.com'); + + $this->orderRepository->shouldReceive('updateWhere')->never(); + $this->orderAuditLogService->shouldReceive('logOrderUpdate')->never(); + + $result = $this->service->editOrder( + order: $order, + firstName: 'John', + lastName: 'Doe', + email: 'same@example.com', + ipAddress: '192.168.1.1', + userAgent: 'Mozilla/5.0' + ); + + $this->assertTrue($result->success); + $this->assertFalse($result->shortIdChanged); + $this->assertNull($result->newShortId); + $this->assertFalse($result->emailChanged); + + Mail::assertNothingSent(); + } + + public function testMultipleFieldsUpdateTogether(): void + { + $order = Mockery::mock(OrderDomainObject::class); + $order->shouldReceive('getId')->andReturn(123); + $order->shouldReceive('getEventId')->andReturn(789); + $order->shouldReceive('getFirstName')->andReturn('John'); + $order->shouldReceive('getLastName')->andReturn('Doe'); + $order->shouldReceive('getEmail')->andReturn('old@example.com'); + $order->shouldReceive('getShortId')->andReturn('o_oldshortid123'); + + $this->orderRepository + ->shouldReceive('updateWhere') + ->once() + ->withArgs(function ($attributes, $where) { + return isset($attributes['first_name']) + && $attributes['first_name'] === 'Jane' + && isset($attributes['last_name']) + && $attributes['last_name'] === 'Smith' + && isset($attributes['email']) + && $attributes['email'] === 'new@example.com' + && isset($attributes['short_id']) + && str_starts_with($attributes['short_id'], 'o_') + && $where === ['id' => 123]; + }) + ->andReturn(1); + + $mockOrderWithRelations = Mockery::mock(OrderDomainObject::class); + $mockOrderWithRelations->shouldReceive('getLatestInvoice')->andReturn(null); + + $this->orderRepository + ->shouldReceive('loadRelation') + ->andReturnSelf(); + + $this->orderRepository + ->shouldReceive('findById') + ->with(123) + ->andReturn($mockOrderWithRelations); + + $mockEventSettings = Mockery::mock(EventSettingDomainObject::class); + $mockOrganizer = Mockery::mock(OrganizerDomainObject::class); + $mockEvent = Mockery::mock(EventDomainObject::class); + $mockEvent->shouldReceive('getEventSettings')->andReturn($mockEventSettings); + $mockEvent->shouldReceive('getOrganizer')->andReturn($mockOrganizer); + + $this->eventRepository + ->shouldReceive('loadRelation') + ->andReturnSelf(); + + $this->eventRepository + ->shouldReceive('findById') + ->with(789) + ->andReturn($mockEvent); + + $this->sendOrderDetailsService + ->shouldReceive('sendCustomerOrderSummary') + ->once(); + + $this->orderAuditLogService + ->shouldReceive('logOrderUpdate') + ->once() + ->withArgs(function ($ord, $oldValues, $newValues, $ip, $ua) use ($order) { + return $ord === $order + && $oldValues['first_name'] === 'John' + && $oldValues['last_name'] === 'Doe' + && $oldValues['email'] === 'old@example.com' + && isset($oldValues['short_id']) + && $newValues['first_name'] === 'Jane' + && $newValues['last_name'] === 'Smith' + && $newValues['email'] === 'new@example.com' + && isset($newValues['short_id']) && str_starts_with($newValues['short_id'], 'o_') + && $ip === '192.168.1.1' + && $ua === 'Mozilla/5.0'; + }); + + $result = $this->service->editOrder( + order: $order, + firstName: 'Jane', + lastName: 'Smith', + email: 'new@example.com', + ipAddress: '192.168.1.1', + userAgent: 'Mozilla/5.0' + ); + + $this->assertTrue($result->success); + $this->assertTrue($result->shortIdChanged); + $this->assertNotNull($result->newShortId); + $this->assertStringStartsWith('o_', $result->newShortId); + $this->assertTrue($result->emailChanged); + } + + public function testOnlyEmailUpdate(): void + { + $order = Mockery::mock(OrderDomainObject::class); + $order->shouldReceive('getId')->andReturn(123); + $order->shouldReceive('getEventId')->andReturn(789); + $order->shouldReceive('getFirstName')->andReturn('John'); + $order->shouldReceive('getLastName')->andReturn('Doe'); + $order->shouldReceive('getEmail')->andReturn('old@example.com'); + $order->shouldReceive('getShortId')->andReturn('o_oldshortid123'); + + $this->orderRepository + ->shouldReceive('updateWhere') + ->once() + ->withArgs(function ($attributes, $where) { + return isset($attributes['email']) + && $attributes['email'] === 'new@example.com' + && isset($attributes['short_id']) + && str_starts_with($attributes['short_id'], 'o_') + && $where === ['id' => 123]; + }) + ->andReturn(1); + + $mockOrderWithRelations = Mockery::mock(OrderDomainObject::class); + $mockOrderWithRelations->shouldReceive('getLatestInvoice')->andReturn(null); + + $this->orderRepository + ->shouldReceive('loadRelation') + ->andReturnSelf(); + + $this->orderRepository + ->shouldReceive('findById') + ->with(123) + ->andReturn($mockOrderWithRelations); + + $mockEventSettings = Mockery::mock(EventSettingDomainObject::class); + $mockOrganizer = Mockery::mock(OrganizerDomainObject::class); + $mockEvent = Mockery::mock(EventDomainObject::class); + $mockEvent->shouldReceive('getEventSettings')->andReturn($mockEventSettings); + $mockEvent->shouldReceive('getOrganizer')->andReturn($mockOrganizer); + + $this->eventRepository + ->shouldReceive('loadRelation') + ->andReturnSelf(); + + $this->eventRepository + ->shouldReceive('findById') + ->with(789) + ->andReturn($mockEvent); + + $this->sendOrderDetailsService + ->shouldReceive('sendCustomerOrderSummary') + ->once(); + + $this->orderAuditLogService + ->shouldReceive('logOrderUpdate') + ->once(); + + $result = $this->service->editOrder( + order: $order, + firstName: null, + lastName: null, + email: 'new@example.com', + ipAddress: '192.168.1.1', + userAgent: 'Mozilla/5.0' + ); + + $this->assertTrue($result->success); + $this->assertTrue($result->shortIdChanged); + $this->assertNotNull($result->newShortId); + $this->assertStringStartsWith('o_', $result->newShortId); + $this->assertTrue($result->emailChanged); + } + + public function testOnlyFirstNameUpdate(): void + { + $order = Mockery::mock(OrderDomainObject::class); + $order->shouldReceive('getId')->andReturn(123); + $order->shouldReceive('getEventId')->andReturn(789); + $order->shouldReceive('getFirstName')->andReturn('John'); + $order->shouldReceive('getLastName')->andReturn('Doe'); + $order->shouldReceive('getEmail')->andReturn('same@example.com'); + + $this->orderRepository + ->shouldReceive('updateWhere') + ->once() + ->withArgs(function ($attributes, $where) { + return $attributes === ['first_name' => 'Jane'] + && $where === ['id' => 123]; + }) + ->andReturn(1); + + $mockEventSettings = Mockery::mock(EventSettingDomainObject::class); + $mockEventSettings->shouldReceive('getSupportEmail')->andReturn('support@example.com'); + $mockOrganizer = Mockery::mock(OrganizerDomainObject::class); + $mockEvent = Mockery::mock(EventDomainObject::class); + $mockEvent->shouldReceive('getEventSettings')->andReturn($mockEventSettings); + $mockEvent->shouldReceive('getOrganizer')->andReturn($mockOrganizer); + + $this->eventRepository + ->shouldReceive('loadRelation') + ->andReturnSelf(); + + $this->eventRepository + ->shouldReceive('findById') + ->with(789) + ->andReturn($mockEvent); + + $this->orderAuditLogService + ->shouldReceive('logOrderUpdate') + ->once(); + + $result = $this->service->editOrder( + order: $order, + firstName: 'Jane', + lastName: null, + email: null, + ipAddress: '192.168.1.1', + userAgent: 'Mozilla/5.0' + ); + + $this->assertTrue($result->success); + $this->assertFalse($result->shortIdChanged); + $this->assertNull($result->newShortId); + $this->assertFalse($result->emailChanged); + + Mail::assertQueued(OrderDetailsChangedMail::class, function ($mail) { + return $mail->hasTo('same@example.com'); + }); + } + + protected function tearDown(): void + { + Mockery::close(); + parent::tearDown(); + } +} diff --git a/backend/tests/Unit/Services/Domain/SelfService/SelfServiceResendEmailServiceTest.php b/backend/tests/Unit/Services/Domain/SelfService/SelfServiceResendEmailServiceTest.php new file mode 100644 index 0000000000..ef83d435a8 --- /dev/null +++ b/backend/tests/Unit/Services/Domain/SelfService/SelfServiceResendEmailServiceTest.php @@ -0,0 +1,353 @@ +sendAttendeeTicketService = Mockery::mock(SendAttendeeTicketService::class); + $this->sendOrderDetailsService = Mockery::mock(SendOrderDetailsService::class); + $this->attendeeRepository = Mockery::mock(AttendeeRepositoryInterface::class); + $this->orderRepository = Mockery::mock(OrderRepositoryInterface::class); + $this->eventRepository = Mockery::mock(EventRepositoryInterface::class); + $this->orderAuditLogService = Mockery::mock(OrderAuditLogService::class); + + $this->service = new SelfServiceResendEmailService( + $this->sendAttendeeTicketService, + $this->sendOrderDetailsService, + $this->attendeeRepository, + $this->orderRepository, + $this->eventRepository, + $this->orderAuditLogService + ); + } + + public function testResendAttendeeTicketSuccessfully(): void + { + $attendeeId = 456; + $orderId = 123; + $eventId = 1; + + $order = Mockery::mock(OrderDomainObject::class); + $orderItems = Mockery::mock(OrderItemDomainObject::class); + $order->shouldReceive('getOrderItems')->andReturn(collect([$orderItems])); + + $attendee = Mockery::mock(AttendeeDomainObject::class); + $attendee->shouldReceive('getOrder')->andReturn($order); + + $eventSettings = Mockery::mock(EventSettingDomainObject::class); + $organizer = Mockery::mock(OrganizerDomainObject::class); + + $event = Mockery::mock(EventDomainObject::class); + $event->shouldReceive('getEventSettings')->andReturn($eventSettings); + $event->shouldReceive('getOrganizer')->andReturn($organizer); + + $this->attendeeRepository + ->shouldReceive('loadRelation') + ->once() + ->with(Mockery::type(Relationship::class)) + ->andReturnSelf(); + + $this->attendeeRepository + ->shouldReceive('findFirstWhere') + ->once() + ->with([ + 'id' => $attendeeId, + 'order_id' => $orderId, + 'event_id' => $eventId, + ]) + ->andReturn($attendee); + + $this->eventRepository + ->shouldReceive('loadRelation') + ->twice() + ->andReturnSelf(); + + $this->eventRepository + ->shouldReceive('findById') + ->once() + ->with($eventId) + ->andReturn($event); + + $this->sendAttendeeTicketService + ->shouldReceive('send') + ->once() + ->withArgs(function ($ord, $att, $evt, $evtSettings, $org) use ($order, $attendee, $event, $eventSettings, $organizer) { + return $ord === $order + && $att === $attendee + && $evt === $event + && $evtSettings === $eventSettings + && $org === $organizer; + }); + + $this->orderAuditLogService + ->shouldReceive('logEmailResent') + ->once() + ->withArgs(function ($action, $evtId, $ordId, $attId, $ip, $ua) use ($eventId, $orderId, $attendeeId) { + return $action === OrderAuditAction::ATTENDEE_EMAIL_RESENT->value + && $evtId === $eventId + && $ordId === $orderId + && $attId === $attendeeId + && $ip === '192.168.1.1' + && $ua === 'Mozilla/5.0'; + }); + + $this->service->resendAttendeeTicket( + attendeeId: $attendeeId, + orderId: $orderId, + eventId: $eventId, + ipAddress: '192.168.1.1', + userAgent: 'Mozilla/5.0' + ); + + $this->assertTrue(true); + } + + public function testResendOrderConfirmationSuccessfully(): void + { + $orderId = 123; + $eventId = 1; + + $orderItems = Mockery::mock(OrderItemDomainObject::class); + $attendees = Mockery::mock(AttendeeDomainObject::class); + $invoice = Mockery::mock(InvoiceDomainObject::class); + + $order = Mockery::mock(OrderDomainObject::class); + $order->shouldReceive('getOrderItems')->andReturn(collect([$orderItems])); + $order->shouldReceive('getAttendees')->andReturn(collect([$attendees])); + $order->shouldReceive('getLatestInvoice')->andReturn($invoice); + + $eventSettings = Mockery::mock(EventSettingDomainObject::class); + $organizer = Mockery::mock(OrganizerDomainObject::class); + + $event = Mockery::mock(EventDomainObject::class); + $event->shouldReceive('getEventSettings')->andReturn($eventSettings); + $event->shouldReceive('getOrganizer')->andReturn($organizer); + + $this->orderRepository + ->shouldReceive('loadRelation') + ->times(3) + ->andReturnSelf(); + + $this->orderRepository + ->shouldReceive('findFirstWhere') + ->once() + ->with([ + 'id' => $orderId, + 'event_id' => $eventId, + ]) + ->andReturn($order); + + $this->eventRepository + ->shouldReceive('loadRelation') + ->twice() + ->andReturnSelf(); + + $this->eventRepository + ->shouldReceive('findById') + ->once() + ->with($eventId) + ->andReturn($event); + + $this->sendOrderDetailsService + ->shouldReceive('sendCustomerOrderSummary') + ->once() + ->withArgs(function ($ord, $evt, $org, $evtSettings, $inv) use ($order, $event, $organizer, $eventSettings, $invoice) { + return $ord === $order + && $evt === $event + && $org === $organizer + && $evtSettings === $eventSettings + && $inv === $invoice; + }); + + $this->orderAuditLogService + ->shouldReceive('logEmailResent') + ->once() + ->withArgs(function ($action, $evtId, $ordId, $attId, $ip, $ua) use ($eventId, $orderId) { + return $action === OrderAuditAction::ORDER_EMAIL_RESENT->value + && $evtId === $eventId + && $ordId === $orderId + && $attId === null + && $ip === '192.168.1.1' + && $ua === 'Mozilla/5.0'; + }); + + $this->service->resendOrderConfirmation( + orderId: $orderId, + eventId: $eventId, + ipAddress: '192.168.1.1', + userAgent: 'Mozilla/5.0' + ); + + $this->assertTrue(true); + } + + public function testResendAttendeeTicketLoadsCorrectRelationships(): void + { + $attendeeId = 456; + $orderId = 123; + $eventId = 1; + + $order = Mockery::mock(OrderDomainObject::class); + $attendee = Mockery::mock(AttendeeDomainObject::class); + $attendee->shouldReceive('getOrder')->andReturn($order); + + $eventSettings = Mockery::mock(EventSettingDomainObject::class); + $organizer = Mockery::mock(OrganizerDomainObject::class); + $event = Mockery::mock(EventDomainObject::class); + $event->shouldReceive('getEventSettings')->andReturn($eventSettings); + $event->shouldReceive('getOrganizer')->andReturn($organizer); + + $this->attendeeRepository + ->shouldReceive('loadRelation') + ->once() + ->with(Mockery::on(function ($relationship) { + return $relationship instanceof Relationship + && $relationship->getDomainObject() === OrderDomainObject::class + && $relationship->getName() === 'order'; + })) + ->andReturnSelf(); + + $this->attendeeRepository + ->shouldReceive('findFirstWhere') + ->once() + ->andReturn($attendee); + + $this->eventRepository + ->shouldReceive('loadRelation') + ->once() + ->with(Mockery::on(function ($relationship) { + return $relationship instanceof Relationship + && $relationship->getDomainObject() === OrganizerDomainObject::class; + })) + ->andReturnSelf(); + + $this->eventRepository + ->shouldReceive('loadRelation') + ->once() + ->with(EventSettingDomainObject::class) + ->andReturnSelf(); + + $this->eventRepository + ->shouldReceive('findById') + ->once() + ->andReturn($event); + + $this->sendAttendeeTicketService + ->shouldReceive('send') + ->once(); + + $this->orderAuditLogService + ->shouldReceive('logEmailResent') + ->once(); + + $this->service->resendAttendeeTicket( + attendeeId: $attendeeId, + orderId: $orderId, + eventId: $eventId, + ipAddress: '192.168.1.1', + userAgent: 'Mozilla/5.0' + ); + + $this->assertTrue(true); + } + + public function testResendOrderConfirmationLoadsCorrectRelationships(): void + { + $orderId = 123; + $eventId = 1; + + $invoice = Mockery::mock(InvoiceDomainObject::class); + $order = Mockery::mock(OrderDomainObject::class); + $order->shouldReceive('getLatestInvoice')->andReturn($invoice); + + $eventSettings = Mockery::mock(EventSettingDomainObject::class); + $organizer = Mockery::mock(OrganizerDomainObject::class); + $event = Mockery::mock(EventDomainObject::class); + $event->shouldReceive('getEventSettings')->andReturn($eventSettings); + $event->shouldReceive('getOrganizer')->andReturn($organizer); + + $loadRelationCallCount = 0; + $this->orderRepository + ->shouldReceive('loadRelation') + ->times(3) + ->with(Mockery::on(function ($domainObject) use (&$loadRelationCallCount) { + $loadRelationCallCount++; + return in_array($domainObject, [ + OrderItemDomainObject::class, + AttendeeDomainObject::class, + InvoiceDomainObject::class, + ]); + })) + ->andReturnSelf(); + + $this->orderRepository + ->shouldReceive('findFirstWhere') + ->once() + ->andReturn($order); + + $this->eventRepository + ->shouldReceive('loadRelation') + ->twice() + ->andReturnSelf(); + + $this->eventRepository + ->shouldReceive('findById') + ->once() + ->andReturn($event); + + $this->sendOrderDetailsService + ->shouldReceive('sendCustomerOrderSummary') + ->once(); + + $this->orderAuditLogService + ->shouldReceive('logEmailResent') + ->once(); + + $this->service->resendOrderConfirmation( + orderId: $orderId, + eventId: $eventId, + ipAddress: '192.168.1.1', + userAgent: 'Mozilla/5.0' + ); + + $this->assertTrue(true); + } + + protected function tearDown(): void + { + Mockery::close(); + parent::tearDown(); + } +} diff --git a/frontend/src/api/organizer.client.ts b/frontend/src/api/organizer.client.ts index 2d2c7b3f35..13df90237e 100644 --- a/frontend/src/api/organizer.client.ts +++ b/frontend/src/api/organizer.client.ts @@ -65,19 +65,56 @@ export const organizerClient = { reportType: string, startDate?: string | null, endDate?: string | null, - currency?: string | null + currency?: string | null, + eventId?: IdParam | null, + page?: number, + perPage?: number ) => { const params = new URLSearchParams(); if (startDate) params.append('start_date', startDate); if (endDate) params.append('end_date', endDate); if (currency) params.append('currency', currency); + if (eventId) params.append('event_id', String(eventId)); + if (page) params.append('page', String(page)); + if (perPage) params.append('per_page', String(perPage)); const queryString = params.toString() ? `?${params.toString()}` : ''; - const response = await api.get>( + const response = await api.get<{ + data: any[]; + pagination?: { + total: number; + page: number; + per_page: number; + last_page: number; + }; + }>( `organizers/${organizerId}/reports/${reportType}${queryString}` ); return response.data; }, + + exportOrganizerReport: async ( + organizerId: IdParam, + reportType: string, + startDate?: string | null, + endDate?: string | null, + currency?: string | null, + eventId?: IdParam | null + ): Promise => { + const params = new URLSearchParams(); + if (startDate) params.append('start_date', startDate); + if (endDate) params.append('end_date', endDate); + if (currency) params.append('currency', currency); + if (eventId) params.append('event_id', String(eventId)); + + const queryString = params.toString() ? `?${params.toString()}` : ''; + const response = await api.get( + `organizers/${organizerId}/reports/${reportType}/export${queryString}`, + { responseType: 'blob' } + ); + + return new Blob([response.data]); + }, } export const organizerPublicClient = { diff --git a/frontend/src/api/self-service.client.ts b/frontend/src/api/self-service.client.ts new file mode 100644 index 0000000000..a004a95452 --- /dev/null +++ b/frontend/src/api/self-service.client.ts @@ -0,0 +1,71 @@ +import {publicApi} from "./public-client.ts"; +import {IdParam} from "../types.ts"; + +export interface SelfServiceUpdateResult { + success: boolean; + short_id_changed: boolean; + new_short_id?: string; + message: string; + warning?: string; + email_sent?: boolean; +} + +export interface EditAttendeeData { + first_name?: string; + last_name?: string; + email?: string; +} + +export interface EditOrderData { + first_name?: string; + last_name?: string; + email?: string; +} + +export const selfServiceClient = { + editAttendee: async ( + eventId: IdParam, + orderShortId: string, + attendeeShortId: string, + data: EditAttendeeData + ): Promise => { + const response = await publicApi.patch( + `/events/${eventId}/order/${orderShortId}/attendees/${attendeeShortId}`, + data + ); + return response.data; + }, + + editOrder: async ( + eventId: IdParam, + orderShortId: string, + data: EditOrderData + ): Promise => { + const response = await publicApi.patch( + `/events/${eventId}/order/${orderShortId}`, + data + ); + return response.data; + }, + + resendAttendeeTicket: async ( + eventId: IdParam, + orderShortId: string, + attendeeShortId: string + ): Promise<{ success: boolean; message: string }> => { + const response = await publicApi.post( + `/events/${eventId}/order/${orderShortId}/attendees/${attendeeShortId}/resend-ticket` + ); + return response.data; + }, + + resendOrderConfirmation: async ( + eventId: IdParam, + orderShortId: string + ): Promise<{ success: boolean; message: string }> => { + const response = await publicApi.post( + `/events/${eventId}/order/${orderShortId}/resend-confirmation` + ); + return response.data; + }, +}; diff --git a/frontend/src/components/common/InlineOrderSummary/InlineOrderSummary.module.scss b/frontend/src/components/common/InlineOrderSummary/InlineOrderSummary.module.scss index 465b6dcddc..5f605ea1ab 100644 --- a/frontend/src/components/common/InlineOrderSummary/InlineOrderSummary.module.scss +++ b/frontend/src/components/common/InlineOrderSummary/InlineOrderSummary.module.scss @@ -153,6 +153,10 @@ white-space: nowrap; overflow: hidden; text-overflow: ellipsis; + + @include mixins.respond-below('sm') { + max-width: 220px; + } } .lineItemQuantity { diff --git a/frontend/src/components/common/InlineOrderSummary/index.tsx b/frontend/src/components/common/InlineOrderSummary/index.tsx index fb7926c6bd..5f70770e4c 100644 --- a/frontend/src/components/common/InlineOrderSummary/index.tsx +++ b/frontend/src/components/common/InlineOrderSummary/index.tsx @@ -94,7 +94,8 @@ export const InlineOrderSummary = ({ {order.order_items?.map((item) => (
- {item.item_name} + {item.item_name} {/* eslint-disable-next-line lingui/no-unlocalized-strings */} × {item.quantity}
diff --git a/frontend/src/components/common/OrganizerReportTable/index.tsx b/frontend/src/components/common/OrganizerReportTable/index.tsx index 0fcab41f9e..e10db9c61a 100644 --- a/frontend/src/components/common/OrganizerReportTable/index.tsx +++ b/frontend/src/components/common/OrganizerReportTable/index.tsx @@ -1,19 +1,22 @@ -import {ComboboxItem, Group, Select, Skeleton, Table as MantineTable} from '@mantine/core'; +import {Button, ComboboxItem, Group, Select, Skeleton, Table as MantineTable, Text} from '@mantine/core'; import {t} from '@lingui/macro'; import {DatePickerInput} from "@mantine/dates"; -import {IconArrowDown, IconArrowsSort, IconArrowUp, IconCalendar} from "@tabler/icons-react"; +import {IconArrowDown, IconArrowsSort, IconArrowUp, IconCalendar, IconDownload} from "@tabler/icons-react"; import {useMemo, useState} from "react"; import {PageTitle} from "../PageTitle"; -import {DownloadCsvButton} from "../DownloadCsvButton"; import {Table, TableHead} from "../Table"; +import {Pagination} from "../Pagination"; import '@mantine/dates/styles.css'; import {useGetOrganizerReport} from "../../../queries/useGetOrganizerReport.ts"; import {useParams} from "react-router"; import {Organizer} from "../../../types.ts"; +import {organizerClient} from "../../../api/organizer.client.ts"; import dayjs from 'dayjs'; import utc from 'dayjs/plugin/utc'; import timezone from 'dayjs/plugin/timezone'; import classes from './OrganizerReportTable.module.scss'; +import {downloadBinary} from "../../../utilites/download.ts"; +import {showError, showSuccess} from "../../../utilites/notifications.tsx"; dayjs.extend(utc); dayjs.extend(timezone); @@ -39,10 +42,10 @@ interface OrganizerReportProps { defaultEndDate?: Date; onDateRangeChange?: (range: [Date | null, Date | null]) => void; enableDownload?: boolean; - downloadFileName?: string; showCustomDatePicker?: boolean; showCurrencyFilter?: boolean; availableCurrencies?: string[]; + eventId?: number | null; } const TIME_PERIODS = [ @@ -58,6 +61,8 @@ const TIME_PERIODS = [ {value: 'custom', label: t`Custom Range`} ]; +const ROWS_PER_PAGE = 1000; + const OrganizerReportTable = >({ title, columns, @@ -66,25 +71,38 @@ const OrganizerReportTable = >({ defaultEndDate = new Date(), onDateRangeChange, enableDownload = true, - downloadFileName = 'report.csv', - showCustomDatePicker = false, organizer, showCurrencyFilter = true, availableCurrencies = [], + eventId, }: OrganizerReportProps) => { - const timezone = organizer.timezone || 'UTC'; + const tz = organizer.timezone || 'UTC'; const [dateRange, setDateRange] = useState<[Date | null, Date | null]>([ - dayjs(defaultStartDate).tz(timezone).toDate(), - dayjs(defaultEndDate).tz(timezone).toDate() + dayjs(defaultStartDate).tz(tz).toDate(), + dayjs(defaultEndDate).tz(tz).toDate() ]); const [selectedPeriod, setSelectedPeriod] = useState('90d'); - const [showDatePickerInput, setShowDatePickerInput] = useState(showCustomDatePicker); + const [showDatePickerInput, setShowDatePickerInput] = useState(false); const [sortField, setSortField] = useState(null); const [sortDirection, setSortDirection] = useState<'asc' | 'desc' | null>(null); const [selectedCurrency, setSelectedCurrency] = useState(null); + const [currentPage, setCurrentPage] = useState(1); const {reportType, organizerId} = useParams(); - const reportQuery = useGetOrganizerReport(organizerId, reportType || '', dateRange[0], dateRange[1], selectedCurrency); - const data = (reportQuery.data || []) as T[]; + + const reportQuery = useGetOrganizerReport( + organizerId, + reportType || '', + dateRange[0], + dateRange[1], + selectedCurrency, + eventId, + currentPage, + ROWS_PER_PAGE + ); + + const reportData = reportQuery.data; + const data = (reportData?.data || []) as T[]; + const pagination = reportData?.pagination; const calculateDateRange = (period: string): [Date | null, Date | null] => { if (period === 'custom') { @@ -93,8 +111,8 @@ const OrganizerReportTable = >({ } setShowDatePickerInput(false); - let end = dayjs().tz(timezone).endOf('day'); - let start = dayjs().tz(timezone); + let end = dayjs().tz(tz).endOf('day'); + let start = dayjs().tz(tz); switch (period) { case '24h': @@ -138,13 +156,14 @@ const OrganizerReportTable = >({ setSelectedPeriod(value); const newRange = calculateDateRange(value); setDateRange(newRange); + setCurrentPage(1); onDateRangeChange?.(newRange); }; const handleDateRangeChange = (newRange: [Date | null, Date | null]) => { const [start, end] = newRange; - const tzStart = start ? dayjs(start).tz(timezone) : null; - const tzEnd = end ? dayjs(end).tz(timezone) : null; + const tzStart = start ? dayjs(start).tz(tz) : null; + const tzEnd = end ? dayjs(end).tz(tz) : null; const tzRange: [Date | null, Date | null] = [ tzStart?.toDate() || null, @@ -152,11 +171,42 @@ const OrganizerReportTable = >({ ]; setDateRange(tzRange); + setCurrentPage(1); onDateRangeChange?.(tzRange); }; const handleCurrencyChange = (value: string | null) => { setSelectedCurrency(value === '' ? null : value); + setCurrentPage(1); + }; + + const handlePageChange = (page: number) => { + setCurrentPage(page); + }; + + const [isExporting, setIsExporting] = useState(false); + + const handleExport = async () => { + if (!organizerId || !reportType) return; + + setIsExporting(true); + try { + const blob = await organizerClient.exportOrganizerReport( + organizerId, + reportType, + dateRange[0]?.toISOString(), + dateRange[1]?.toISOString(), + selectedCurrency, + eventId + ); + const filename = `${reportType}_${dayjs().format('YYYY-MM-DD_HH-mm-ss')}.csv`; + downloadBinary(blob, filename); + showSuccess(t`Export successful`); + } catch { + showError(t`Failed to export report. Please try again.`); + } finally { + setIsExporting(false); + } }; const handleSort = (field: keyof T) => { @@ -202,14 +252,6 @@ const OrganizerReportTable = >({ }); }, [data, sortField, sortDirection]); - const csvHeaders = columns.map(col => col.label); - const csvData = sortedData.map(row => - columns.map(col => { - const value = row[col.key]; - return typeof value === 'number' ? value.toString() : value; - }) - ); - const emptyStateMessage = () => { const wrapper = (message: React.ReactNode) => ( @@ -250,6 +292,9 @@ const OrganizerReportTable = >({ ...availableCurrencies.map(curr => ({value: curr, label: curr})) ]; + const totalPages = pagination?.last_page || 1; + const totalRows = pagination?.total || 0; + return ( <> @@ -286,21 +331,32 @@ const OrganizerReportTable = >({ placeholder="Pick dates range" value={dateRange} onChange={handleDateRangeChange} - minDate={dayjs().subtract(1, 'year').tz(timezone).toDate()} - maxDate={dayjs().tz(timezone).toDate()} + minDate={dayjs().subtract(1, 'year').tz(tz).toDate()} + maxDate={dayjs().tz(tz).toDate()} className={classes.datePicker} /> )} {enableDownload && ( - } + variant="light" + onClick={handleExport} + loading={isExporting} className={classes.downloadButton} - /> + > + {t`Export CSV`} + )} + + {totalRows > 0 && ( + + {t`Showing ${sortedData.length} of ${totalRows} records`} + {totalPages > 1 && ` (${t`Page`} ${currentPage} ${t`of`} ${totalPages})`} + + )} + @@ -311,7 +367,7 @@ const OrganizerReportTable = >({ style={{cursor: column.sortable ? 'pointer' : 'default', minWidth: '180px'}} > - {t`${column.label}`} + {column.label} {column.sortable && getSortIcon(column.key)} @@ -334,6 +390,14 @@ const OrganizerReportTable = >({ ))}
+ + {totalPages > 1 && ( + + )} ); }; diff --git a/frontend/src/components/common/SelfServiceSettings/index.tsx b/frontend/src/components/common/SelfServiceSettings/index.tsx new file mode 100644 index 0000000000..08e4e49708 --- /dev/null +++ b/frontend/src/components/common/SelfServiceSettings/index.tsx @@ -0,0 +1,34 @@ +import {t} from "@lingui/macro"; +import {Switch} from "@mantine/core"; + +interface SelfServiceSettingsProps { + value: boolean; + onChange: (value: boolean) => void; + disabled?: boolean; + isDefault?: boolean; +} + +export const SelfServiceSettings = ({ + value, + onChange, + disabled = false, + isDefault = false, +}: SelfServiceSettingsProps) => { + const label = isDefault + ? t`Enable attendee self-service by default` + : t`Enable attendee self-service`; + + const description = isDefault + ? t`When enabled, new events will allow attendees to manage their own ticket details via a secure link. This can be overridden per event.` + : t`Allow attendees to update their ticket information (name, email) via a secure link sent with their order confirmation.`; + + return ( + onChange(event.currentTarget.checked)} + disabled={disabled} + /> + ); +}; diff --git a/frontend/src/components/layouts/Checkout/Checkout.module.scss b/frontend/src/components/layouts/Checkout/Checkout.module.scss index bd365e3346..7eb0c82f57 100644 --- a/frontend/src/components/layouts/Checkout/Checkout.module.scss +++ b/frontend/src/components/layouts/Checkout/Checkout.module.scss @@ -1,7 +1,7 @@ @use "../../../styles/mixins"; $font-display: 'Outfit', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; -$font-body: 'Plus Jakarta Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; +$font-body: 'Outfit', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; .container { display: flex; diff --git a/frontend/src/components/layouts/EventHomepage/EventHomepage.module.scss b/frontend/src/components/layouts/EventHomepage/EventHomepage.module.scss index 67117a8eea..1637015eb8 100644 --- a/frontend/src/components/layouts/EventHomepage/EventHomepage.module.scss +++ b/frontend/src/components/layouts/EventHomepage/EventHomepage.module.scss @@ -2,7 +2,7 @@ // Design tokens $font-display: 'Outfit', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; -$font-body: 'Plus Jakarta Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; +$font-body: 'Outfit', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; $radius-xl: 28px; $radius-lg: 20px; diff --git a/frontend/src/components/layouts/OrganizerHomepage/EventCard/EventCard.module.scss b/frontend/src/components/layouts/OrganizerHomepage/EventCard/EventCard.module.scss index 60785ff4a3..2375552ead 100644 --- a/frontend/src/components/layouts/OrganizerHomepage/EventCard/EventCard.module.scss +++ b/frontend/src/components/layouts/OrganizerHomepage/EventCard/EventCard.module.scss @@ -2,7 +2,7 @@ // Design tokens - matching OrganizerHomepage $font-display: 'Outfit', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; -$font-body: 'Plus Jakarta Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; +$font-body: 'Outfit', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; $radius-lg: 20px; $radius-md: 14px; diff --git a/frontend/src/components/layouts/OrganizerHomepage/OrganizerHomepage.module.scss b/frontend/src/components/layouts/OrganizerHomepage/OrganizerHomepage.module.scss index ee22073ee5..46cb458793 100644 --- a/frontend/src/components/layouts/OrganizerHomepage/OrganizerHomepage.module.scss +++ b/frontend/src/components/layouts/OrganizerHomepage/OrganizerHomepage.module.scss @@ -2,7 +2,7 @@ // Design tokens - matching EventHomepage $font-display: 'Outfit', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; -$font-body: 'Plus Jakarta Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; +$font-body: 'Outfit', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; $radius-xl: 28px; $radius-lg: 20px; diff --git a/frontend/src/components/routes/event/Settings/Sections/MiscSettings/index.tsx b/frontend/src/components/routes/event/Settings/Sections/MiscSettings/index.tsx index 14fa334ba1..0893c3d204 100644 --- a/frontend/src/components/routes/event/Settings/Sections/MiscSettings/index.tsx +++ b/frontend/src/components/routes/event/Settings/Sections/MiscSettings/index.tsx @@ -12,6 +12,7 @@ import {useGetEventSettings} from "../../../../../../queries/useGetEventSettings import {HeadingWithDescription} from "../../../../../common/Card/CardHeading"; import {CustomSelect, ItemProps} from "../../../../../common/CustomSelect"; import {IconCoin, IconCoins} from "@tabler/icons-react"; +import {SelfServiceSettings} from "../../../../../common/SelfServiceSettings"; export const MiscSettings = () => { const {eventId} = useParams(); @@ -21,6 +22,7 @@ export const MiscSettings = () => { initialValues: { price_display_mode: 'EXCLUSIVE', hide_getting_started_page: false, + allow_attendee_self_edit: false, } }); const formErrorHandle = useFormErrorResponseHandler(); @@ -30,6 +32,7 @@ export const MiscSettings = () => { form.setValues({ price_display_mode: eventSettingsQuery.data.price_display_mode, hide_getting_started_page: eventSettingsQuery.data.hide_getting_started_page, + allow_attendee_self_edit: eventSettingsQuery.data.allow_attendee_self_edit ?? false, }); } }, [eventSettingsQuery.isFetched]); @@ -90,6 +93,11 @@ export const MiscSettings = () => { description={t`Hide the getting started page from the sidebar`} /> + form.setFieldValue('allow_attendee_self_edit', value)} + /> + diff --git a/frontend/src/components/routes/organizer/Reports/PlatformFeesReport/PlatformFeesReport.module.scss b/frontend/src/components/routes/organizer/Reports/PlatformFeesReport/PlatformFeesReport.module.scss new file mode 100644 index 0000000000..7637384e89 --- /dev/null +++ b/frontend/src/components/routes/organizer/Reports/PlatformFeesReport/PlatformFeesReport.module.scss @@ -0,0 +1,4 @@ +.eventFilter { + margin-bottom: 1.5rem; + max-width: 300px; +} diff --git a/frontend/src/components/routes/organizer/Reports/PlatformFeesReport/index.tsx b/frontend/src/components/routes/organizer/Reports/PlatformFeesReport/index.tsx new file mode 100644 index 0000000000..6bad53ddc7 --- /dev/null +++ b/frontend/src/components/routes/organizer/Reports/PlatformFeesReport/index.tsx @@ -0,0 +1,147 @@ +import {Link, useParams} from "react-router"; +import {useGetOrganizer} from "../../../../../queries/useGetOrganizer.ts"; +import {useGetOrganizerStats} from "../../../../../queries/useGetOrganizerStats.ts"; +import {useGetOrganizerEvents} from "../../../../../queries/useGetOrganizerEvents.ts"; +import {formatCurrency} from "../../../../../utilites/currency.ts"; +import OrganizerReportTable from "../../../../common/OrganizerReportTable"; +import {t} from "@lingui/macro"; +import {Alert, Select} from "@mantine/core"; +import {IconAlertTriangle} from "@tabler/icons-react"; +import {useState} from "react"; +import classes from "./PlatformFeesReport.module.scss"; + +const PlatformFeesReport = () => { + const {organizerId} = useParams(); + const organizerQuery = useGetOrganizer(organizerId); + const organizer = organizerQuery.data; + const [selectedEventId, setSelectedEventId] = useState(null); + + const statsQuery = useGetOrganizerStats(organizerId, organizer?.currency); + const allCurrencies = statsQuery.data?.all_organizers_currencies || []; + + const eventsQuery = useGetOrganizerEvents(organizerId, { + pageNumber: 1, + perPage: 100, + }); + const events = eventsQuery.data?.data || []; + + if (!organizer) { + return null; + } + + const eventOptions = [ + {value: '', label: t`All Events`}, + ...events.map(event => ({ + value: String(event.id), + label: event.title || '' + })) + ]; + + const columns = [ + { + key: 'event_name' as const, + label: t`Event`, + sortable: true, + render: (value: string, row: any) => ( + + {value} + + ) + }, + { + key: 'payment_date' as const, + label: t`Payment Date`, + sortable: true, + render: (value: string) => value ? new Date(value).toLocaleDateString() : '-' + }, + { + key: 'order_reference' as const, + label: t`Order Ref`, + sortable: false, + render: (value: string, row: any) => ( + + {value} + + ) + }, + { + key: 'amount_paid' as const, + label: t`Amount Paid`, + sortable: true, + render: (value: number, row: any) => formatCurrency(value, row.currency) + }, + { + key: 'fee_amount' as const, + label: t`Hi.Events Fee`, + sortable: true, + render: (value: number, row: any) => formatCurrency(value, row.currency) + }, + { + key: 'vat_rate' as const, + label: t`VAT Rate`, + sortable: false, + render: (value: number) => value ? `${(value * 100).toFixed(0)}%` : '-' + }, + { + key: 'vat_amount' as const, + label: t`VAT on Fee`, + sortable: true, + render: (value: number, row: any) => value ? formatCurrency(value, row.currency) : '-' + }, + { + key: 'total_fee' as const, + label: t`Total Fee`, + sortable: true, + render: (value: number, row: any) => formatCurrency(value, row.currency) + }, + { + key: 'currency' as const, + label: t`Currency`, + sortable: false + }, + { + key: 'payment_intent_id' as const, + label: t`Stripe Payment ID`, + sortable: false, + render: (value: string) => value || '-' + } + ]; + + return ( + <> + } + title={t`Important Notice`} + color="yellow" + mb="lg" + > + {t`This report is for informational purposes only. Always consult with a tax professional before using this data for accounting or tax purposes. Please cross-reference with your Stripe dashboard as Hi.Events may be missing historical data.`} + + +
+