diff --git a/CLAUDE.md b/CLAUDE.md index ef7c84edb8..7abdd754e4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -45,6 +45,13 @@ cd docker/development ## Development Guidelines +### Comments — hard rule for all code (backend, frontend, SCSS) +- **DON'T** add explanatory comments. The code must speak for itself. +- This includes "why" comments justifying a design choice ("X is intentionally omitted because…", "matches the rest of the section rhythm…", "Views live on the per-event-per-day table…", "Online events satisfy the where requirement…"). If you'd write that, rename a variable / extract a function / restructure the code instead, or just leave it implicit. +- Functional annotations are fine: PHPDoc `@throws` / `@return` / `@param`, `// TODO(handle:owner)` linked to a tracked task, schema comments inside SQL migrations that future migrations depend on. +- Never restate what the next line does. If a reviewer can read the diff and understand it, the comment is noise. +- If you're tempted to leave a comment "for the next agent", **don't** — write it as a CLAUDE.md note instead. + ### Backend #### Architecture Flow @@ -58,8 +65,8 @@ cd docker/development - **ALWAYS** wrap all translatable strings in `__()` helper - Domain Objects are auto-generated via `php artisan generate-domain-objects` - never edit manually - **Always** create unit tests for new features in `backend/tests/Unit/` +- **DON'T** add comments — see the comments rule above. No exceptions for "this seems useful context". - **NEVER leave dead code.** Code that has no production callers — unused methods, unused DTO fields, unused constants, columns that are written but never read, classes only called from tests — must be deleted, not left "for future use". This applies to both backend and frontend. If you add a method speculatively, wire it to a real caller in the same change or remove it. The same rule applies after refactors: if something becomes unreferenced, it goes. Confirm with grep before claiming a method or class is reachable. -- **DON'T** add comments unless absolutely necessary - **ALWAYS** sanitize user-provided content with `HtmlPurifierService` before storing, especially content rendered as HTML #### DTOs @@ -94,6 +101,7 @@ cd docker/development - **DON'T** use `RefreshDatabase` - use `DatabaseTransactions` instead - Unit tests extend Laravel's TestCase, not PHPUnit's TestCase - Use Mockery for mocking +- **Unit suite (`tests/Unit/`) is for pure isolation tests** — no DB, no HTTP, no real container resolution. If a test uses `DatabaseTransactions`, hits the DB (raw `DB::` calls, factories that persist, repository methods that query), or boots significant framework state, it's an integration test and belongs in `tests/Feature/` (mirror the path, e.g. `tests/Feature/Repository/Eloquent/`). Running `--testsuite=Unit` must stay fast and DB-free. - Tests run against a dedicated `hievents_test` database, configured via `backend/.env.testing` and enforced by `phpunit.xml`. The local docker-compose creates this database automatically via `docker/development/pgsql-init/`. If your existing pgsql volume predates this script, create the DB once with: `docker compose -f docker-compose.dev.yml exec pgsql psql -U username -d backend -c 'CREATE DATABASE hievents_test OWNER username;'` - Database name **must end in `_test`**. Enforced globally by a `final` guard in `tests/TestCase.php::guardAgainstNonTestDatabase()` which runs on every test that boots Laravel — no per-test opt-in needed and no way to bypass. diff --git a/backend/VERSION b/backend/VERSION new file mode 100755 index 0000000000..e69de29bb2 diff --git a/backend/app/Console/Commands/SeedDevDashboardDataCommand.php b/backend/app/Console/Commands/SeedDevDashboardDataCommand.php new file mode 100644 index 0000000000..5cfe4f460d --- /dev/null +++ b/backend/app/Console/Commands/SeedDevDashboardDataCommand.php @@ -0,0 +1,262 @@ +environment('production') && !$this->option('force')) { + $this->error('Refusing to run in production. Pass --force to override.'); + return self::FAILURE; + } + + $eventId = (int)$this->argument('eventId'); + $days = (int)$this->option('days'); + + $event = DB::table('events')->where('id', $eventId)->first(); + if ($event === null) { + $this->error("Event {$eventId} not found."); + return self::FAILURE; + } + + $occurrence = DB::table('event_occurrences') + ->where('event_id', $eventId) + ->whereNull('deleted_at') + ->first(); + + if ($occurrence === null) { + $this->error("Event {$eventId} has no event_occurrence rows. Cannot seed daily statistics."); + return self::FAILURE; + } + + $this->info("Seeding {$days} days of dummy data for event {$eventId} ({$event->title}, {$event->currency})"); + + DB::transaction(function () use ($eventId, $days, $event, $occurrence) { + $this->cleanup($eventId); + + $today = CarbonImmutable::today(); + $aggregateGross = 0.0; + $aggregateTax = 0.0; + $aggregateFee = 0.0; + $aggregateRefunded = 0.0; + $aggregateOrders = 0; + $aggregateProducts = 0; + $aggregateAttendees = 0; + $aggregateViews = 0; + $aggregateCancelled = 0; + + $bar = $this->output->createProgressBar($days); + $bar->start(); + + for ($i = $days - 1; $i >= 0; $i--) { + $date = $today->subDays($i); + + $isWeekend = in_array($date->dayOfWeek, [0, 6], true); + $orderCount = $this->randomOrderCount($i, $isWeekend); + + $dayProducts = 0; + $dayAttendees = 0; + $dayGross = 0.0; + $dayTax = 0.0; + $dayFee = 0.0; + $dayRefunded = 0.0; + $dayOrdersCreated = 0; + $dayOrdersCancelled = 0; + $dayViews = random_int(20, 180) + ($isWeekend ? 50 : 0); + + for ($n = 0; $n < $orderCount; $n++) { + $items = random_int(1, 4); + $unitPrice = $this->randomChoice([15.00, 25.00, 35.00, 45.00, 75.00]); + $beforeAdditions = round($unitPrice * $items, 2); + $tax = round($beforeAdditions * 0.135, 2); + $fee = round($beforeAdditions * 0.025, 2); + $gross = round($beforeAdditions + $tax + $fee, 2); + + $isCancelled = random_int(1, 100) <= 8; + $refundedAmount = 0.0; + if (!$isCancelled && random_int(1, 100) <= 6) { + $refundedAmount = $gross; + } + + $createdAt = $date->setTime(random_int(8, 22), random_int(0, 59), random_int(0, 59)); + + $status = $isCancelled ? 'CANCELLED' : 'COMPLETED'; + $paymentStatus = $isCancelled ? null : 'PAYMENT_RECEIVED'; + $refundStatus = $refundedAmount > 0 ? 'REFUNDED' : null; + + DB::table('orders')->insert([ + 'short_id' => IdHelper::shortId(IdHelper::ORDER_PREFIX), + 'public_id' => IdHelper::publicId(IdHelper::ORDER_PREFIX), + 'event_id' => $eventId, + 'currency' => $event->currency, + 'first_name' => $this->randomChoice(self::FIRST_NAMES), + 'last_name' => $this->randomChoice(self::LAST_NAMES), + 'email' => 'seed' . random_int(1000, 9999) . '@example.com', + 'status' => $status, + 'payment_status' => $paymentStatus, + 'refund_status' => $refundStatus, + 'total_before_additions' => $beforeAdditions, + 'total_gross' => $gross, + 'total_tax' => $tax, + 'total_fee' => $fee, + 'total_refunded' => $refundedAmount, + 'is_manually_created' => false, + 'notes' => self::SEED_NOTE, + 'locale' => 'en', + 'payment_provider' => 'STRIPE', + 'created_at' => $createdAt, + 'updated_at' => $createdAt, + ]); + + if ($isCancelled) { + $dayOrdersCancelled++; + continue; + } + + $dayOrdersCreated++; + $dayProducts += $items; + $dayAttendees += $items; + $dayGross += $gross; + $dayTax += $tax; + $dayFee += $fee; + $dayRefunded += $refundedAmount; + } + + DB::table('event_occurrence_daily_statistics')->upsert( + [ + [ + 'event_id' => $eventId, + 'event_occurrence_id' => $occurrence->id, + 'date' => $date->toDateString(), + 'products_sold' => $dayProducts, + 'attendees_registered' => $dayAttendees, + 'sales_total_gross' => $dayGross, + 'sales_total_before_additions' => round($dayGross - $dayTax - $dayFee, 2), + 'total_tax' => $dayTax, + 'total_fee' => $dayFee, + 'orders_created' => $dayOrdersCreated, + 'orders_cancelled' => $dayOrdersCancelled, + 'total_refunded' => $dayRefunded, + 'version' => 0, + 'created_at' => $date, + 'updated_at' => $date, + ], + ], + ['event_occurrence_id', 'date'], + [ + 'products_sold', 'attendees_registered', + 'sales_total_gross', 'sales_total_before_additions', + 'total_tax', 'total_fee', + 'orders_created', 'orders_cancelled', 'total_refunded', + 'updated_at', + ], + ); + + $aggregateGross += $dayGross; + $aggregateTax += $dayTax; + $aggregateFee += $dayFee; + $aggregateRefunded += $dayRefunded; + $aggregateOrders += $dayOrdersCreated; + $aggregateProducts += $dayProducts; + $aggregateAttendees += $dayAttendees; + $aggregateViews += $dayViews; + $aggregateCancelled += $dayOrdersCancelled; + + $bar->advance(); + } + + $bar->finish(); + $this->newLine(); + + DB::table('event_statistics') + ->where('event_id', $eventId) + ->update([ + 'sales_total_gross' => $aggregateGross, + 'sales_total_before_additions' => round($aggregateGross - $aggregateTax - $aggregateFee, 2), + 'total_tax' => $aggregateTax, + 'total_fee' => $aggregateFee, + 'total_refunded' => $aggregateRefunded, + 'orders_created' => $aggregateOrders, + 'orders_cancelled' => $aggregateCancelled, + 'products_sold' => $aggregateProducts, + 'attendees_registered' => $aggregateAttendees, + 'total_views' => $aggregateViews, + 'unique_views' => (int)round($aggregateViews * 0.65), + 'updated_at' => now(), + ]); + + $this->table( + ['Metric', 'Total over period'], + [ + ['Orders (completed)', $aggregateOrders], + ['Orders (cancelled)', $aggregateCancelled], + ['Products sold', $aggregateProducts], + ['Attendees', $aggregateAttendees], + ['Gross sales', number_format($aggregateGross, 2) . ' ' . $event->currency], + ['Tax', number_format($aggregateTax, 2) . ' ' . $event->currency], + ['Fees', number_format($aggregateFee, 2) . ' ' . $event->currency], + ['Refunded', number_format($aggregateRefunded, 2) . ' ' . $event->currency], + ['Page views', $aggregateViews], + ], + ); + }); + + $this->info('Done.'); + return self::SUCCESS; + } + + private function cleanup(int $eventId): void + { + $deletedOrders = DB::table('orders') + ->where('event_id', $eventId) + ->where('notes', self::SEED_NOTE) + ->delete(); + + $this->line("Cleaned up {$deletedOrders} prior seed orders. Daily statistics will be upserted for the seeded window."); + } + + private function randomOrderCount(int $daysAgo, bool $isWeekend): int + { + $base = $isWeekend ? random_int(4, 12) : random_int(1, 7); + + if ($daysAgo > 30) { + $base = (int)round($base * 0.6); + } + if (random_int(1, 100) <= 4) { + $base += random_int(8, 20); + } + return max(0, $base); + } + + private function randomChoice(array $items) + { + return $items[array_rand($items)]; + } +} diff --git a/backend/app/DomainObjects/Generated/EventDomainObjectAbstract.php b/backend/app/DomainObjects/Generated/EventDomainObjectAbstract.php index 19d2bf039b..aa28369236 100644 --- a/backend/app/DomainObjects/Generated/EventDomainObjectAbstract.php +++ b/backend/app/DomainObjects/Generated/EventDomainObjectAbstract.php @@ -18,6 +18,7 @@ abstract class EventDomainObjectAbstract extends \HiEvents\DomainObjects\Abstrac final public const TITLE = 'title'; final public const DESCRIPTION = 'description'; final public const STATUS = 'status'; + final public const LOCATION_DETAILS = 'location_details'; final public const CURRENCY = 'currency'; final public const TIMEZONE = 'timezone'; final public const ATTRIBUTES = 'attributes'; @@ -39,6 +40,7 @@ abstract class EventDomainObjectAbstract extends \HiEvents\DomainObjects\Abstrac protected string $title; protected ?string $description = null; protected ?string $status = null; + protected array|string|null $location_details = null; protected string $currency = 'USD'; protected ?string $timezone = null; protected array|string|null $attributes = null; @@ -63,6 +65,7 @@ public function toArray(): array 'title' => $this->title ?? null, 'description' => $this->description ?? null, 'status' => $this->status ?? null, + 'location_details' => $this->location_details ?? null, 'currency' => $this->currency ?? null, 'timezone' => $this->timezone ?? null, 'attributes' => $this->attributes ?? null, @@ -166,6 +169,17 @@ public function getStatus(): ?string return $this->status; } + public function setLocationDetails(array|string|null $location_details): self + { + $this->location_details = $location_details; + return $this; + } + + public function getLocationDetails(): array|string|null + { + return $this->location_details; + } + public function setCurrency(string $currency): self { $this->currency = $currency; diff --git a/backend/app/DomainObjects/Generated/EventSettingDomainObjectAbstract.php b/backend/app/DomainObjects/Generated/EventSettingDomainObjectAbstract.php index 654948ff84..fca6299a61 100644 --- a/backend/app/DomainObjects/Generated/EventSettingDomainObjectAbstract.php +++ b/backend/app/DomainObjects/Generated/EventSettingDomainObjectAbstract.php @@ -30,6 +30,9 @@ abstract class EventSettingDomainObjectAbstract extends \HiEvents\DomainObjects\ final public const HOMEPAGE_PRIMARY_COLOR = 'homepage_primary_color'; final public const HOMEPAGE_SECONDARY_TEXT_COLOR = 'homepage_secondary_text_color'; final public const HOMEPAGE_SECONDARY_COLOR = 'homepage_secondary_color'; + final public const LOCATION_DETAILS = 'location_details'; + final public const ONLINE_EVENT_CONNECTION_DETAILS = 'online_event_connection_details'; + final public const IS_ONLINE_EVENT = 'is_online_event'; final public const ALLOW_SEARCH_ENGINE_INDEXING = 'allow_search_engine_indexing'; final public const SEO_TITLE = 'seo_title'; final public const SEO_DESCRIPTION = 'seo_description'; @@ -38,7 +41,6 @@ abstract class EventSettingDomainObjectAbstract extends \HiEvents\DomainObjects\ final public const SEO_KEYWORDS = 'seo_keywords'; final public const NOTIFY_ORGANIZER_OF_NEW_ORDERS = 'notify_organizer_of_new_orders'; final public const PRICE_DISPLAY_MODE = 'price_display_mode'; - final public const HIDE_GETTING_STARTED_PAGE = 'hide_getting_started_page'; final public const SHOW_SHARE_BUTTONS = 'show_share_buttons'; final public const HOMEPAGE_BODY_BACKGROUND_COLOR = 'homepage_body_background_color'; final public const HOMEPAGE_BACKGROUND_TYPE = 'homepage_background_type'; @@ -85,6 +87,9 @@ abstract class EventSettingDomainObjectAbstract extends \HiEvents\DomainObjects\ protected ?string $homepage_primary_color = null; protected ?string $homepage_secondary_text_color = null; protected ?string $homepage_secondary_color = null; + protected array|string|null $location_details = null; + protected ?string $online_event_connection_details = null; + protected bool $is_online_event = false; protected bool $allow_search_engine_indexing = true; protected ?string $seo_title = null; protected ?string $seo_description = null; @@ -93,7 +98,6 @@ abstract class EventSettingDomainObjectAbstract extends \HiEvents\DomainObjects\ protected ?string $seo_keywords = null; protected bool $notify_organizer_of_new_orders = true; protected string $price_display_mode = 'INCLUSIVE'; - protected bool $hide_getting_started_page = false; protected bool $show_share_buttons = true; protected ?string $homepage_body_background_color = null; protected string $homepage_background_type = 'COLOR'; @@ -143,6 +147,9 @@ public function toArray(): array 'homepage_primary_color' => $this->homepage_primary_color ?? null, 'homepage_secondary_text_color' => $this->homepage_secondary_text_color ?? null, 'homepage_secondary_color' => $this->homepage_secondary_color ?? null, + 'location_details' => $this->location_details ?? null, + 'online_event_connection_details' => $this->online_event_connection_details ?? null, + 'is_online_event' => $this->is_online_event ?? null, 'allow_search_engine_indexing' => $this->allow_search_engine_indexing ?? null, 'seo_title' => $this->seo_title ?? null, 'seo_description' => $this->seo_description ?? null, @@ -151,7 +158,6 @@ public function toArray(): array 'seo_keywords' => $this->seo_keywords ?? null, 'notify_organizer_of_new_orders' => $this->notify_organizer_of_new_orders ?? null, 'price_display_mode' => $this->price_display_mode ?? null, - 'hide_getting_started_page' => $this->hide_getting_started_page ?? null, 'show_share_buttons' => $this->show_share_buttons ?? null, 'homepage_body_background_color' => $this->homepage_body_background_color ?? null, 'homepage_background_type' => $this->homepage_background_type ?? null, @@ -400,6 +406,39 @@ public function getHomepageSecondaryColor(): ?string return $this->homepage_secondary_color; } + public function setLocationDetails(array|string|null $location_details): self + { + $this->location_details = $location_details; + return $this; + } + + public function getLocationDetails(): array|string|null + { + return $this->location_details; + } + + public function setOnlineEventConnectionDetails(?string $online_event_connection_details): self + { + $this->online_event_connection_details = $online_event_connection_details; + return $this; + } + + public function getOnlineEventConnectionDetails(): ?string + { + return $this->online_event_connection_details; + } + + public function setIsOnlineEvent(bool $is_online_event): self + { + $this->is_online_event = $is_online_event; + return $this; + } + + public function getIsOnlineEvent(): bool + { + return $this->is_online_event; + } + public function setAllowSearchEngineIndexing(bool $allow_search_engine_indexing): self { $this->allow_search_engine_indexing = $allow_search_engine_indexing; @@ -488,17 +527,6 @@ public function getPriceDisplayMode(): string return $this->price_display_mode; } - public function setHideGettingStartedPage(bool $hide_getting_started_page): self - { - $this->hide_getting_started_page = $hide_getting_started_page; - return $this; - } - - public function getHideGettingStartedPage(): bool - { - return $this->hide_getting_started_page; - } - public function setShowShareButtons(bool $show_share_buttons): self { $this->show_share_buttons = $show_share_buttons; diff --git a/backend/app/DomainObjects/Generated/OrganizerSettingDomainObjectAbstract.php b/backend/app/DomainObjects/Generated/OrganizerSettingDomainObjectAbstract.php index a0d4fd53a4..b09f7c756a 100644 --- a/backend/app/DomainObjects/Generated/OrganizerSettingDomainObjectAbstract.php +++ b/backend/app/DomainObjects/Generated/OrganizerSettingDomainObjectAbstract.php @@ -24,6 +24,7 @@ abstract class OrganizerSettingDomainObjectAbstract extends \HiEvents\DomainObje final public const CREATED_AT = 'created_at'; final public const UPDATED_AT = 'updated_at'; final public const DELETED_AT = 'deleted_at'; + final public const LOCATION_DETAILS = 'location_details'; 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'; @@ -45,6 +46,7 @@ abstract class OrganizerSettingDomainObjectAbstract extends \HiEvents\DomainObje protected ?string $created_at = null; protected ?string $updated_at = null; protected ?string $deleted_at = null; + protected array|string|null $location_details = null; 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; @@ -69,6 +71,7 @@ public function toArray(): array 'created_at' => $this->created_at ?? null, 'updated_at' => $this->updated_at ?? null, 'deleted_at' => $this->deleted_at ?? null, + 'location_details' => $this->location_details ?? null, '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, @@ -232,6 +235,17 @@ public function getDeletedAt(): ?string return $this->deleted_at; } + public function setLocationDetails(array|string|null $location_details): self + { + $this->location_details = $location_details; + return $this; + } + + public function getLocationDetails(): array|string|null + { + return $this->location_details; + } + public function setDefaultAttendeeDetailsCollectionMethod( string $default_attendee_details_collection_method, ): self { diff --git a/backend/app/Http/Actions/Events/Stats/GetEventStatsAction.php b/backend/app/Http/Actions/Events/Stats/GetEventStatsAction.php index 33f91ea0ba..6868a622f9 100644 --- a/backend/app/Http/Actions/Events/Stats/GetEventStatsAction.php +++ b/backend/app/Http/Actions/Events/Stats/GetEventStatsAction.php @@ -9,28 +9,68 @@ use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\JsonResource; +use Illuminate\Support\Carbon; +use Illuminate\Support\Facades\Validator; +use Illuminate\Validation\ValidationException; class GetEventStatsAction extends BaseAction { + private const MAX_RANGE_DAYS = 370; + public function __construct( private readonly GetEventStatsHandler $eventStatsHandler ) { } + /** + * @throws ValidationException + */ public function __invoke(int $eventId, Request $request): JsonResponse { $this->isActionAuthorized($eventId, EventDomainObject::class); - $dateRangePreset = $request->query('date_range', 'month'); + $validated = $this->validateDateRange($request); + $occurrenceIdQuery = $request->query('occurrence_id'); $stats = $this->eventStatsHandler->handle(EventStatsRequestDTO::fromArray([ 'event_id' => $eventId, - 'date_range_preset' => $dateRangePreset, + 'date_range_preset' => $request->query('date_range', 'month'), + 'start_date' => $validated['start_date'] ?? null, + 'end_date' => $validated['end_date'] ?? null, 'occurrence_id' => $occurrenceIdQuery !== null ? (int)$occurrenceIdQuery : null, ])); return $this->resourceResponse(JsonResource::class, $stats); } + + /** + * @return array{start_date: ?string, end_date: ?string} + * @throws ValidationException + */ + private function validateDateRange(Request $request): array + { + $validated = Validator::make( + $request->only(['start_date', 'end_date']), + [ + 'start_date' => 'nullable|date|required_with:end_date|before_or_equal:end_date', + 'end_date' => 'nullable|date|required_with:start_date|after_or_equal:start_date', + ], + )->validate(); + + if (!empty($validated['start_date']) && !empty($validated['end_date'])) { + $days = Carbon::parse($validated['start_date'])->diffInDays(Carbon::parse($validated['end_date'])); + if ($days > self::MAX_RANGE_DAYS) { + throw ValidationException::withMessages([ + 'start_date' => __('Date range must be less than :days days.', ['days' => self::MAX_RANGE_DAYS]), + ]); + } + } + + return [ + 'start_date' => $validated['start_date'] ?? null, + 'end_date' => $validated['end_date'] ?? null, + ]; + } } diff --git a/backend/app/Http/Actions/Organizers/Stats/GetOrganizerStatsAction.php b/backend/app/Http/Actions/Organizers/Stats/GetOrganizerStatsAction.php index 474bf84cc5..fdb3ac9fc4 100644 --- a/backend/app/Http/Actions/Organizers/Stats/GetOrganizerStatsAction.php +++ b/backend/app/Http/Actions/Organizers/Stats/GetOrganizerStatsAction.php @@ -8,23 +8,34 @@ use HiEvents\Services\Application\Handlers\Organizer\GetOrganizerStatsHandler; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; +use Illuminate\Support\Carbon; +use Illuminate\Support\Facades\Validator; +use Illuminate\Validation\ValidationException; class GetOrganizerStatsAction extends BaseAction { + private const MAX_RANGE_DAYS = 370; + public function __construct( private readonly GetOrganizerStatsHandler $getOrganizerStatsHandler, - ) - { - } + ) {} + /** + * @throws ValidationException + */ public function __invoke(Request $request, int $organizerId): JsonResponse { $this->isActionAuthorized($organizerId, OrganizerDomainObject::class); + $validated = $this->validateDateRange($request); + $organizerStats = $this->getOrganizerStatsHandler->handle(new GetOrganizerStatsRequestDTO( organizerId: $organizerId, accountId: $this->getAuthenticatedAccountId(), currencyCode: $request->get('currency_code'), + startDate: $validated['start_date'], + endDate: $validated['end_date'], + dateRangePreset: $request->query('date_range', 'month'), )); return $this->jsonResponse( @@ -32,4 +43,33 @@ public function __invoke(Request $request, int $organizerId): JsonResponse wrapInData: true, ); } + + /** + * @return array{start_date: ?string, end_date: ?string} + * @throws ValidationException + */ + private function validateDateRange(Request $request): array + { + $validated = Validator::make( + $request->only(['start_date', 'end_date']), + [ + 'start_date' => 'nullable|date|required_with:end_date|before_or_equal:end_date', + 'end_date' => 'nullable|date|required_with:start_date|after_or_equal:start_date', + ], + )->validate(); + + if (!empty($validated['start_date']) && !empty($validated['end_date'])) { + $days = Carbon::parse($validated['start_date'])->diffInDays(Carbon::parse($validated['end_date'])); + if ($days > self::MAX_RANGE_DAYS) { + throw ValidationException::withMessages([ + 'start_date' => __('Date range must be less than :days days.', ['days' => self::MAX_RANGE_DAYS]), + ]); + } + } + + return [ + 'start_date' => $validated['start_date'] ?? null, + 'end_date' => $validated['end_date'] ?? null, + ]; + } } diff --git a/backend/app/Http/Request/EventSettings/UpdateEventSettingsRequest.php b/backend/app/Http/Request/EventSettings/UpdateEventSettingsRequest.php index 046faf1d9a..36ea577458 100644 --- a/backend/app/Http/Request/EventSettings/UpdateEventSettingsRequest.php +++ b/backend/app/Http/Request/EventSettings/UpdateEventSettingsRequest.php @@ -46,8 +46,6 @@ public function rules(): array 'price_display_mode' => [Rule::in(PriceDisplayMode::valuesArray())], - 'hide_getting_started_page' => ['boolean'], - // Payment settings 'payment_providers' => ['array'], 'payment_providers.*' => ['string', Rule::in(PaymentProviders::valuesArray())], diff --git a/backend/app/Repository/DTO/Organizer/OrganizerDailyStatsResponseDTO.php b/backend/app/Repository/DTO/Organizer/OrganizerDailyStatsResponseDTO.php new file mode 100644 index 0000000000..4c57cce253 --- /dev/null +++ b/backend/app/Repository/DTO/Organizer/OrganizerDailyStatsResponseDTO.php @@ -0,0 +1,15 @@ + @@ -32,15 +35,15 @@ public function getSitemapOrganizers(int $page, int $perPage): LengthAwarePagina { return $this->handleResults($this->model ->select([ - 'organizers.' . OrganizerDomainObjectAbstract::ID, - 'organizers.' . OrganizerDomainObjectAbstract::NAME, - 'organizers.' . OrganizerDomainObjectAbstract::UPDATED_AT, + 'organizers.'.OrganizerDomainObjectAbstract::ID, + 'organizers.'.OrganizerDomainObjectAbstract::NAME, + 'organizers.'.OrganizerDomainObjectAbstract::UPDATED_AT, ]) ->join('organizer_settings', 'organizers.id', '=', 'organizer_settings.organizer_id') - ->where('organizers.' . OrganizerDomainObjectAbstract::STATUS, OrganizerStatus::LIVE->name) - ->where('organizer_settings.' . OrganizerSettingDomainObjectAbstract::ALLOW_SEARCH_ENGINE_INDEXING, true) - ->whereNull('organizers.' . OrganizerDomainObjectAbstract::DELETED_AT) - ->orderBy('organizers.' . OrganizerDomainObjectAbstract::ID) + ->where('organizers.'.OrganizerDomainObjectAbstract::STATUS, OrganizerStatus::LIVE->name) + ->where('organizer_settings.'.OrganizerSettingDomainObjectAbstract::ALLOW_SEARCH_ENGINE_INDEXING, true) + ->whereNull('organizers.'.OrganizerDomainObjectAbstract::DELETED_AT) + ->orderBy('organizers.'.OrganizerDomainObjectAbstract::ID) ->paginate($perPage, ['*'], 'page', $page)); } @@ -49,39 +52,65 @@ public function getSitemapOrganizerCount(): int return $this->model ->newQuery() ->join('organizer_settings', 'organizers.id', '=', 'organizer_settings.organizer_id') - ->where('organizers.' . OrganizerDomainObjectAbstract::STATUS, OrganizerStatus::LIVE->name) - ->where('organizer_settings.' . OrganizerSettingDomainObjectAbstract::ALLOW_SEARCH_ENGINE_INDEXING, true) - ->whereNull('organizers.' . OrganizerDomainObjectAbstract::DELETED_AT) + ->where('organizers.'.OrganizerDomainObjectAbstract::STATUS, OrganizerStatus::LIVE->name) + ->where('organizer_settings.'.OrganizerSettingDomainObjectAbstract::ALLOW_SEARCH_ENGINE_INDEXING, true) + ->whereNull('organizers.'.OrganizerDomainObjectAbstract::DELETED_AT) ->count(); } - public function getOrganizerStats(int $organizerId, int $accountId, string $currencyCode): OrganizerStatsResponseDTO - { - $totalsQuery = <<= :startDate::date + AND eods.date <= :endDate::date + AND eods.deleted_at IS NULL + AND e.deleted_at IS NULL; +SQL; + + $totalsResult = $this->db->selectOne($totalsQuery, [ + 'organizerId' => $organizerId, + 'accountId' => $accountId, + 'currencyCode' => $currencyCode, + 'startDate' => $startDate, + 'endDate' => $endDate, + ]); + + $totalViewsQuery = <<<'SQL' + SELECT COALESCE(SUM(es.total_views), 0) AS total_views FROM event_statistics es JOIN events e ON e.id = es.event_id WHERE e.organizer_id = :organizerId AND e.account_id = :accountId AND e.currency = :currencyCode - AND es.deleted_at IS NULL; - SQL; + AND es.deleted_at IS NULL + AND e.deleted_at IS NULL; +SQL; - $totalsResult = $this->db->selectOne($totalsQuery, [ + $totalViewsResult = $this->db->selectOne($totalViewsQuery, [ 'organizerId' => $organizerId, 'accountId' => $accountId, 'currencyCode' => $currencyCode, ]); - $allOrganizersCurrenciesQuery = << $accountId, ]); + $dailyStats = $this->getDailyOrganizerStats( + organizerId: $organizerId, + accountId: $accountId, + currencyCode: $currencyCode, + startDate: $startDate, + endDate: $endDate, + ); + return new OrganizerStatsResponseDTO( - total_products_sold: (int)($totalsResult->total_products_sold ?? 0), - total_attendees_registered: (int)($totalsResult->attendees_registered ?? 0), - total_orders: (int)($totalsResult->total_orders ?? 0), - total_gross_sales: (float)($totalsResult->total_gross_sales ?? 0), - total_fees: (float)($totalsResult->total_fees ?? 0), - total_tax: (float)($totalsResult->total_tax ?? 0), - total_views: (int)($totalsResult->total_views ?? 0), - total_refunded: (float)($totalsResult->total_refunded ?? 0), + total_products_sold: (int) ($totalsResult->total_products_sold ?? 0), + total_attendees_registered: (int) ($totalsResult->attendees_registered ?? 0), + total_orders: (int) ($totalsResult->total_orders ?? 0), + total_gross_sales: (float) ($totalsResult->total_gross_sales ?? 0), + total_fees: (float) ($totalsResult->total_fees ?? 0), + total_tax: (float) ($totalsResult->total_tax ?? 0), + total_views: (int) ($totalViewsResult->total_views ?? 0), + total_refunded: (float) ($totalsResult->total_refunded ?? 0), currency_code: $currencyCode, + daily_stats: $dailyStats, + start_date: $startDate, + end_date: $endDate, all_organizers_currencies: array_map( - static fn($currency) => $currency->currency, + static fn ($currency) => $currency->currency, $allOrganizersCurrencies ), ); } + + private function getDailyOrganizerStats( + int $organizerId, + int $accountId, + string $currencyCode, + string $startDate, + string $endDate, + ): Collection { + $query = <<<'SQL' + WITH date_series AS ( + SELECT date::date + FROM generate_series( + :startDate::date, + :endDate::date, + '1 day' + ) AS gs(date) + ) + SELECT + ds.date, + COALESCE(SUM(eods.attendees_registered), 0) AS attendees_registered, + COALESCE(SUM(eods.products_sold), 0) AS products_sold, + COALESCE(SUM(eods.sales_total_gross), 0) AS total_sales_gross, + COALESCE(SUM(eods.orders_created), 0) AS orders_created, + COALESCE(SUM(eods.total_refunded), 0) AS total_refunded + FROM date_series ds + LEFT JOIN event_occurrence_daily_statistics eods + ON ds.date = eods.date + AND eods.deleted_at IS NULL + AND eods.event_id IN ( + SELECT e.id FROM events e + WHERE e.organizer_id = :organizerId + AND e.account_id = :accountId + AND e.currency = :currencyCode + AND e.deleted_at IS NULL + ) + GROUP BY ds.date + ORDER BY ds.date ASC; +SQL; + + $results = $this->db->select($query, [ + 'startDate' => $startDate, + 'endDate' => $endDate, + 'organizerId' => $organizerId, + 'accountId' => $accountId, + 'currencyCode' => $currencyCode, + ]); + + $currentTime = Carbon::now('UTC')->toTimeString(); + + return collect($results)->map(function (object $result) use ($currentTime) { + $dateTimeWithCurrentTime = (new Carbon($result->date))->setTimezone('UTC')->format('Y-m-d').' '.$currentTime; + + return new OrganizerDailyStatsResponseDTO( + date: $dateTimeWithCurrentTime, + attendees_registered: (int) $result->attendees_registered, + products_sold: (int) $result->products_sold, + total_sales_gross: (float) $result->total_sales_gross, + orders_created: (int) $result->orders_created, + total_refunded: (float) $result->total_refunded, + ); + }); + } } diff --git a/backend/app/Repository/Interfaces/OrganizerRepositoryInterface.php b/backend/app/Repository/Interfaces/OrganizerRepositoryInterface.php index 069993a93b..97790b6f5f 100644 --- a/backend/app/Repository/Interfaces/OrganizerRepositoryInterface.php +++ b/backend/app/Repository/Interfaces/OrganizerRepositoryInterface.php @@ -13,7 +13,13 @@ */ interface OrganizerRepositoryInterface extends RepositoryInterface { - public function getOrganizerStats(int $organizerId, int $accountId, string $currencyCode): OrganizerStatsResponseDTO; + public function getOrganizerStats( + int $organizerId, + int $accountId, + string $currencyCode, + string $startDate, + string $endDate, + ): OrganizerStatsResponseDTO; public function getSitemapOrganizers(int $page, int $perPage): LengthAwarePaginator; diff --git a/backend/app/Resources/Event/EventSettingsResource.php b/backend/app/Resources/Event/EventSettingsResource.php index 7f93ebacc4..ca1037bdc8 100644 --- a/backend/app/Resources/Event/EventSettingsResource.php +++ b/backend/app/Resources/Event/EventSettingsResource.php @@ -42,7 +42,6 @@ public function toArray($request): array 'notify_organizer_of_new_orders' => $this->getNotifyOrganizerOfNewOrders(), 'price_display_mode' => $this->getPriceDisplayMode(), - 'hide_getting_started_page' => $this->getHideGettingStartedPage(), // Ticket design settings 'ticket_design_settings' => $this->getTicketDesignSettings(), diff --git a/backend/app/Services/Application/Handlers/EventSettings/DTO/UpdateEventSettingsDTO.php b/backend/app/Services/Application/Handlers/EventSettings/DTO/UpdateEventSettingsDTO.php index ccd5b94da6..3ed2ef57fb 100644 --- a/backend/app/Services/Application/Handlers/EventSettings/DTO/UpdateEventSettingsDTO.php +++ b/backend/app/Services/Application/Handlers/EventSettings/DTO/UpdateEventSettingsDTO.php @@ -45,8 +45,6 @@ public function __construct( public readonly ?PriceDisplayMode $price_display_mode = PriceDisplayMode::INCLUSIVE, - public readonly ?bool $hide_getting_started_page = false, - // Payment settings public readonly array $payment_providers = [], public readonly ?string $offline_payment_instructions = null, @@ -115,7 +113,6 @@ public static function createWithDefaults( allow_search_engine_indexing: true, notify_organizer_of_new_orders: null, price_display_mode: PriceDisplayMode::INCLUSIVE, - hide_getting_started_page: false, // Payment defaults payment_providers: [PaymentProviders::STRIPE->value], diff --git a/backend/app/Services/Application/Handlers/EventSettings/PartialUpdateEventSettingsHandler.php b/backend/app/Services/Application/Handlers/EventSettings/PartialUpdateEventSettingsHandler.php index 7980b922cd..65b3d6dfad 100644 --- a/backend/app/Services/Application/Handlers/EventSettings/PartialUpdateEventSettingsHandler.php +++ b/backend/app/Services/Application/Handlers/EventSettings/PartialUpdateEventSettingsHandler.php @@ -68,7 +68,6 @@ public function handle(PartialUpdateEventSettingsDTO $eventSettingsDTO): EventSe 'notify_organizer_of_new_orders' => $eventSettingsDTO->settings['notify_organizer_of_new_orders'] ?? $existingSettings->getNotifyOrganizerOfNewOrders(), 'price_display_mode' => $eventSettingsDTO->settings['price_display_mode'] ?? $existingSettings->getPriceDisplayMode(), - 'hide_getting_started_page' => $eventSettingsDTO->settings['hide_getting_started_page'] ?? $existingSettings->getHideGettingStartedPage(), // Payment settings 'payment_providers' => $eventSettingsDTO->settings['payment_providers'] ?? $existingSettings->getPaymentProviders(), diff --git a/backend/app/Services/Application/Handlers/EventSettings/UpdateEventSettingsHandler.php b/backend/app/Services/Application/Handlers/EventSettings/UpdateEventSettingsHandler.php index 418b2b1f0d..a382126d27 100644 --- a/backend/app/Services/Application/Handlers/EventSettings/UpdateEventSettingsHandler.php +++ b/backend/app/Services/Application/Handlers/EventSettings/UpdateEventSettingsHandler.php @@ -59,7 +59,6 @@ public function handle(UpdateEventSettingsDTO $settings): EventSettingDomainObje 'allow_search_engine_indexing' => $settings->allow_search_engine_indexing, 'notify_organizer_of_new_orders' => $settings->notify_organizer_of_new_orders, 'price_display_mode' => $settings->price_display_mode->name, - 'hide_getting_started_page' => $settings->hide_getting_started_page, // Payment settings 'payment_providers' => $settings->payment_providers, diff --git a/backend/app/Services/Application/Handlers/Organizer/DTO/GetOrganizerStatsRequestDTO.php b/backend/app/Services/Application/Handlers/Organizer/DTO/GetOrganizerStatsRequestDTO.php index 301ea04c99..ff712f27cd 100644 --- a/backend/app/Services/Application/Handlers/Organizer/DTO/GetOrganizerStatsRequestDTO.php +++ b/backend/app/Services/Application/Handlers/Organizer/DTO/GetOrganizerStatsRequestDTO.php @@ -7,8 +7,9 @@ class GetOrganizerStatsRequestDTO public function __construct( public readonly int $organizerId, public readonly int $accountId, - public ?string $currencyCode = null, - ) - { - } + public ?string $currencyCode = null, + public ?string $startDate = null, + public ?string $endDate = null, + public string $dateRangePreset = 'month', + ) {} } diff --git a/backend/app/Services/Application/Handlers/Organizer/GetOrganizerStatsHandler.php b/backend/app/Services/Application/Handlers/Organizer/GetOrganizerStatsHandler.php index 7f29ac06a0..f60c892ccd 100644 --- a/backend/app/Services/Application/Handlers/Organizer/GetOrganizerStatsHandler.php +++ b/backend/app/Services/Application/Handlers/Organizer/GetOrganizerStatsHandler.php @@ -2,6 +2,7 @@ namespace HiEvents\Services\Application\Handlers\Organizer; +use Carbon\Carbon; use HiEvents\Repository\DTO\Organizer\OrganizerStatsResponseDTO; use HiEvents\Repository\Interfaces\OrganizerRepositoryInterface; use HiEvents\Services\Application\Handlers\Organizer\DTO\GetOrganizerStatsRequestDTO; @@ -9,9 +10,7 @@ class GetOrganizerStatsHandler { - public function __construct(private readonly OrganizerRepositoryInterface $repository) - { - } + public function __construct(private readonly OrganizerRepositoryInterface $repository) {} public function handle(GetOrganizerStatsRequestDTO $statsRequestDTO): OrganizerStatsResponseDTO { @@ -24,10 +23,42 @@ public function handle(GetOrganizerStatsRequestDTO $statsRequestDTO): OrganizerS throw new ResourceNotFoundException('Organizer not found'); } + [$startDate, $endDate] = $this->resolveDateRange( + $statsRequestDTO->startDate, + $statsRequestDTO->endDate, + $statsRequestDTO->dateRangePreset, + ); + return $this->repository->getOrganizerStats( organizerId: $statsRequestDTO->organizerId, accountId: $statsRequestDTO->accountId, currencyCode: $statsRequestDTO->currencyCode ?? $organizer->getCurrency(), + startDate: $startDate, + endDate: $endDate, ); } + + /** + * @return array{0: string, 1: string} + */ + private function resolveDateRange(?string $startDate, ?string $endDate, string $preset): array + { + if ($startDate !== null && $endDate !== null) { + return [$startDate, $endDate]; + } + + $end = Carbon::now(); + + $start = match ($preset) { + 'week' => (clone $end)->subDays(7), + 'quarter' => (clone $end)->subDays(90), + 'year' => (clone $end)->subDays(365), + default => (clone $end)->subDays(30), + }; + + return [ + $start->format('Y-m-d H:i:s'), + $end->format('Y-m-d H:i:s'), + ]; + } } diff --git a/backend/app/Services/Domain/Event/EventStatsFetchService.php b/backend/app/Services/Domain/Event/EventStatsFetchService.php index 518ebcf031..42aa522031 100644 --- a/backend/app/Services/Domain/Event/EventStatsFetchService.php +++ b/backend/app/Services/Domain/Event/EventStatsFetchService.php @@ -39,39 +39,59 @@ public function getEventStats(EventStatsRequestDTO $requestData): EventStatsResp // query honest about that scope. $totalsQuery = <<<'SQL' SELECT - COALESCE(SUM(eos.products_sold), 0) AS total_products_sold, - COALESCE(SUM(eos.orders_created), 0) AS total_orders, - COALESCE(SUM(eos.sales_total_gross), 0) AS total_gross_sales, - COALESCE(SUM(eos.total_tax), 0) AS total_tax, - COALESCE(SUM(eos.total_fee), 0) AS total_fees, + COALESCE(SUM(eods.products_sold), 0) AS total_products_sold, + COALESCE(SUM(eods.orders_created), 0) AS total_orders, + COALESCE(SUM(eods.sales_total_gross), 0) AS total_gross_sales, + COALESCE(SUM(eods.total_tax), 0) AS total_tax, + COALESCE(SUM(eods.total_fee), 0) AS total_fees, 0 AS total_views, - COALESCE(SUM(eos.total_refunded), 0) AS total_refunded, - COALESCE(SUM(eos.attendees_registered), 0) AS attendees_registered - FROM event_occurrence_statistics eos - WHERE eos.event_occurrence_id = :occurrenceId - AND eos.event_id = :eventId - AND eos.deleted_at IS NULL; + COALESCE(SUM(eods.total_refunded), 0) AS total_refunded, + COALESCE(SUM(eods.attendees_registered), 0) AS attendees_registered + FROM event_occurrence_daily_statistics eods + WHERE eods.event_occurrence_id = :occurrenceId + AND eods.event_id = :eventId + AND eods.deleted_at IS NULL + AND eods.date >= :startDate::date + AND eods.date <= :endDate::date; SQL; $totalsResult = $this->db->selectOne($totalsQuery, [ 'occurrenceId' => $occurrenceId, 'eventId' => $eventId, + 'startDate' => $requestData->start_date, + 'endDate' => $requestData->end_date, ]); } else { $totalsQuery = <<<'SQL' SELECT - COALESCE(SUM(eos.products_sold), 0) AS total_products_sold, - COALESCE(SUM(eos.orders_created), 0) AS total_orders, - COALESCE(SUM(eos.sales_total_gross), 0) AS total_gross_sales, - COALESCE(SUM(eos.total_tax), 0) AS total_tax, - COALESCE(SUM(eos.total_fee), 0) AS total_fees, - COALESCE((SELECT SUM(es.total_views) FROM event_statistics es WHERE es.event_id = :eventIdViews AND es.deleted_at IS NULL), 0) AS total_views, - COALESCE(SUM(eos.total_refunded), 0) AS total_refunded, - COALESCE(SUM(eos.attendees_registered), 0) AS attendees_registered - FROM event_occurrence_statistics eos - WHERE eos.event_id = :eventId - AND eos.deleted_at IS NULL; + COALESCE(SUM(eods.products_sold), 0) AS total_products_sold, + COALESCE(SUM(eods.orders_created), 0) AS total_orders, + COALESCE(SUM(eods.sales_total_gross), 0) AS total_gross_sales, + COALESCE(SUM(eods.total_tax), 0) AS total_tax, + COALESCE(SUM(eods.total_fee), 0) AS total_fees, + COALESCE(( + SELECT SUM(eds.total_views) + FROM event_daily_statistics eds + WHERE eds.event_id = :eventIdViews + AND eds.deleted_at IS NULL + AND eds.date >= :startDateViews::date + AND eds.date <= :endDateViews::date + ), 0) AS total_views, + COALESCE(SUM(eods.total_refunded), 0) AS total_refunded, + COALESCE(SUM(eods.attendees_registered), 0) AS attendees_registered + FROM event_occurrence_daily_statistics eods + WHERE eods.event_id = :eventId + AND eods.deleted_at IS NULL + AND eods.date >= :startDate::date + AND eods.date <= :endDate::date; SQL; - $totalsResult = $this->db->selectOne($totalsQuery, ['eventId' => $eventId, 'eventIdViews' => $eventId]); + $totalsResult = $this->db->selectOne($totalsQuery, [ + 'eventId' => $eventId, + 'eventIdViews' => $eventId, + 'startDate' => $requestData->start_date, + 'endDate' => $requestData->end_date, + 'startDateViews' => $requestData->start_date, + 'endDateViews' => $requestData->end_date, + ]); } return new EventStatsResponseDTO( diff --git a/backend/database/migrations/2026_05_27_000001_drop_hide_getting_started_page_from_event_settings.php b/backend/database/migrations/2026_05_27_000001_drop_hide_getting_started_page_from_event_settings.php new file mode 100644 index 0000000000..bbceccbcb9 --- /dev/null +++ b/backend/database/migrations/2026_05_27_000001_drop_hide_getting_started_page_from_event_settings.php @@ -0,0 +1,22 @@ +dropColumn('hide_getting_started_page'); + }); + } + + public function down(): void + { + Schema::table('event_settings', function (Blueprint $table) { + $table->boolean('hide_getting_started_page')->default(false); + }); + } +}; diff --git a/backend/tests/Feature/Repository/Eloquent/OrganizerRepositoryTest.php b/backend/tests/Feature/Repository/Eloquent/OrganizerRepositoryTest.php new file mode 100644 index 0000000000..fb0414bf2a --- /dev/null +++ b/backend/tests/Feature/Repository/Eloquent/OrganizerRepositoryTest.php @@ -0,0 +1,217 @@ +repository = $this->app->make(OrganizerRepository::class); + + $user = User::factory()->withAccount()->create(); + $this->accountId = $user->accounts()->first()->id; + + $now = now()->toDateTimeString(); + + $this->organizerId = DB::table('organizers')->insertGetId([ + 'account_id' => $this->accountId, + 'name' => 'Stats Organizer', + 'email' => 'stats-organizer@example.test', + 'currency' => 'USD', + 'timezone' => 'UTC', + 'created_at' => $now, + 'updated_at' => $now, + ]); + + $this->eventAId = DB::table('events')->insertGetId([ + 'title' => 'Event A', + 'account_id' => $this->accountId, + 'user_id' => $user->id, + 'organizer_id' => $this->organizerId, + 'currency' => 'USD', + 'timezone' => 'UTC', + 'short_id' => 'evt_a_'.uniqid(), + 'created_at' => $now, + 'updated_at' => $now, + ]); + + $this->eventBId = DB::table('events')->insertGetId([ + 'title' => 'Event B', + 'account_id' => $this->accountId, + 'user_id' => $user->id, + 'organizer_id' => $this->organizerId, + 'currency' => 'USD', + 'timezone' => 'UTC', + 'short_id' => 'evt_b_'.uniqid(), + 'created_at' => $now, + 'updated_at' => $now, + ]); + + $this->occurrenceAId = DB::table('event_occurrences')->insertGetId([ + 'short_id' => 'occ_a_'.uniqid(), + 'event_id' => $this->eventAId, + 'start_date' => now()->addDay()->toDateTimeString(), + 'end_date' => now()->addDay()->addHours(2)->toDateTimeString(), + 'status' => 'ACTIVE', + 'used_capacity' => 0, + 'is_overridden' => false, + 'created_at' => $now, + 'updated_at' => $now, + ]); + + $this->occurrenceBId = DB::table('event_occurrences')->insertGetId([ + 'short_id' => 'occ_b_'.uniqid(), + 'event_id' => $this->eventBId, + 'start_date' => now()->addDays(2)->toDateTimeString(), + 'end_date' => now()->addDays(2)->addHours(2)->toDateTimeString(), + 'status' => 'ACTIVE', + 'used_capacity' => 0, + 'is_overridden' => false, + 'created_at' => $now, + 'updated_at' => $now, + ]); + } + + public function test_get_organizer_stats_returns_daily_breakdown_and_aggregates_across_date_window(): void + { + // Seed 60 contiguous days of daily stats for both events. Per-event/day + // values are deterministic so we can verify aggregates exactly. + $today = Carbon::now()->startOfDay(); + $rangeStart = (clone $today)->subDays(59); + + $rows = []; + for ($i = 0; $i < 60; $i++) { + $date = (clone $rangeStart)->addDays($i)->toDateString(); + + $rows[] = $this->dailyRow( + eventId: $this->eventAId, + occurrenceId: $this->occurrenceAId, + date: $date, + productsSold: 2, + attendeesRegistered: 3, + salesGross: 10.00, + ordersCreated: 1, + totalRefunded: 1.50, + ); + $rows[] = $this->dailyRow( + eventId: $this->eventBId, + occurrenceId: $this->occurrenceBId, + date: $date, + productsSold: 5, + attendeesRegistered: 7, + salesGross: 25.00, + ordersCreated: 2, + totalRefunded: 0.50, + ); + } + DB::table('event_occurrence_daily_statistics')->insert($rows); + + // Query the most recent 30 days. + $endDate = (clone $today)->format('Y-m-d H:i:s'); + $startDate = (clone $today)->subDays(29)->format('Y-m-d H:i:s'); + + $stats = $this->repository->getOrganizerStats( + organizerId: $this->organizerId, + accountId: $this->accountId, + currencyCode: 'USD', + startDate: $startDate, + endDate: $endDate, + ); + + // Daily series spans the inclusive 30-day window. + $this->assertCount(30, $stats->daily_stats); + $stats->daily_stats->each(function ($row) { + $this->assertInstanceOf(OrganizerDailyStatsResponseDTO::class, $row); + }); + + // Each day across both events: products_sold=2+5=7, attendees=3+7=10, + // gross=10+25=35, orders=1+2=3, refunded=1.5+0.5=2. + $firstDay = $stats->daily_stats->first(); + $this->assertSame(7, $firstDay->products_sold); + $this->assertSame(10, $firstDay->attendees_registered); + $this->assertSame(35.0, $firstDay->total_sales_gross); + $this->assertSame(3, $firstDay->orders_created); + $this->assertSame(2.0, $firstDay->total_refunded); + + // Aggregates across the 30-day window equal the sum of the 30 daily rows. + $this->assertSame(7 * 30, $stats->total_products_sold); + $this->assertSame(10 * 30, $stats->total_attendees_registered); + $this->assertSame(3 * 30, $stats->total_orders); + $this->assertEqualsWithDelta(35.0 * 30, $stats->total_gross_sales, 0.001); + $this->assertEqualsWithDelta(2.0 * 30, $stats->total_refunded, 0.001); + + $this->assertSame('USD', $stats->currency_code); + $this->assertSame($startDate, $stats->start_date); + $this->assertSame($endDate, $stats->end_date); + + // all_organizers_currencies is preserved from the original behaviour. + $this->assertContains('USD', $stats->all_organizers_currencies); + } + + /** + * @return array + */ + private function dailyRow( + int $eventId, + int $occurrenceId, + string $date, + int $productsSold, + int $attendeesRegistered, + float $salesGross, + int $ordersCreated, + float $totalRefunded, + ): array { + $now = now()->toDateTimeString(); + + return [ + 'event_id' => $eventId, + 'event_occurrence_id' => $occurrenceId, + 'date' => $date, + 'products_sold' => $productsSold, + 'attendees_registered' => $attendeesRegistered, + 'sales_total_gross' => $salesGross, + 'sales_total_before_additions' => $salesGross, + 'total_tax' => 0, + 'total_fee' => 0, + 'orders_created' => $ordersCreated, + 'orders_cancelled' => 0, + 'total_refunded' => $totalRefunded, + 'version' => 0, + 'created_at' => $now, + 'updated_at' => $now, + ]; + } +} diff --git a/backend/tests/Unit/Services/Domain/Event/EventStatsFetchServiceTest.php b/backend/tests/Unit/Services/Domain/Event/EventStatsFetchServiceTest.php index bc86f48db6..bda6f11eee 100644 --- a/backend/tests/Unit/Services/Domain/Event/EventStatsFetchServiceTest.php +++ b/backend/tests/Unit/Services/Domain/Event/EventStatsFetchServiceTest.php @@ -33,11 +33,13 @@ public function test_occurrence_scoped_stats_bind_event_id_with_occurrence_id(): ->shouldReceive('selectOne') ->once() ->with( - Mockery::on(static fn (string $sql): bool => str_contains($sql, 'eos.event_occurrence_id = :occurrenceId') - && str_contains($sql, 'eos.event_id = :eventId')), + Mockery::on(static fn (string $sql): bool => str_contains($sql, 'eods.event_occurrence_id = :occurrenceId') + && str_contains($sql, 'eods.event_id = :eventId')), [ 'occurrenceId' => 200, 'eventId' => 10, + 'startDate' => '2026-01-01', + 'endDate' => '2026-01-31', ], ) ->andReturn((object) [ diff --git a/frontend/src/api/event.client.ts b/frontend/src/api/event.client.ts index 6088045910..bfb98d5b64 100644 --- a/frontend/src/api/event.client.ts +++ b/frontend/src/api/event.client.ts @@ -38,10 +38,15 @@ export const eventsClient = { return response.data; }, - getEventStats: async (eventId: IdParam, options: {occurrenceId?: IdParam; dateRange?: string} = {}) => { + getEventStats: async ( + eventId: IdParam, + options: {occurrenceId?: IdParam; dateRange?: string; startDate?: string; endDate?: string} = {}, + ) => { const params = new URLSearchParams(); if (options.occurrenceId) params.set('occurrence_id', String(options.occurrenceId)); if (options.dateRange) params.set('date_range', options.dateRange); + if (options.startDate) params.set('start_date', options.startDate); + if (options.endDate) params.set('end_date', options.endDate); const qs = params.toString(); const response = await api.get>(`events/${eventId}/stats${qs ? '?' + qs : ''}`); return response.data; diff --git a/frontend/src/api/organizer.client.ts b/frontend/src/api/organizer.client.ts index 8b9e58ca7f..f80a8abc5e 100644 --- a/frontend/src/api/organizer.client.ts +++ b/frontend/src/api/organizer.client.ts @@ -65,8 +65,17 @@ export const organizerClient = { return response.data; }, - getOrganizerStats: async (organizerId: IdParam, currencyCode: string) => { - const response = await api.get>('organizers/' + organizerId + '/stats?currency_code=' + currencyCode); + getOrganizerStats: async ( + organizerId: IdParam, + options: {currencyCode: string; startDate?: string; endDate?: string}, + ) => { + const params = new URLSearchParams(); + params.append('currency_code', options.currencyCode); + if (options.startDate) params.append('start_date', options.startDate); + if (options.endDate) params.append('end_date', options.endDate); + const response = await api.get>( + `organizers/${organizerId}/stats?${params.toString()}`, + ); return response.data; }, diff --git a/frontend/src/components/common/EventCard/EventCard.module.scss b/frontend/src/components/common/EventCard/EventCard.module.scss index 76a11548ed..60944cf3ea 100644 --- a/frontend/src/components/common/EventCard/EventCard.module.scss +++ b/frontend/src/components/common/EventCard/EventCard.module.scss @@ -399,3 +399,259 @@ color: var(--mantine-color-text); } } + +.eventCardCompact { + border: 0.5px solid var(--hi-color-border); + border-radius: var(--hi-radius-card); + overflow: hidden; + background: var(--hi-color-white); + transition: background-color 0.12s ease; + container-type: inline-size; + + &:hover { + background-color: var(--mantine-color-gray-0); + + .compactTitle { + color: var(--mantine-color-primary-7); + } + } + + &.isDraft { + .compactImage { + opacity: 0.85; + } + } + + &.isEnded { + .compactImage { + filter: grayscale(60%); + } + } +} + +.cardLinkCompact { + display: flex; + align-items: center; + gap: 12px; + padding: 10px 12px; + text-decoration: none; + color: inherit; + min-height: 76px; + + @container (max-width: 460px) { + gap: 10px; + padding: 10px; + } +} + +.compactThumb { + position: relative; + width: 56px; + height: 56px; + min-width: 56px; + border-radius: var(--hi-radius-sm); + overflow: hidden; + flex-shrink: 0; + + @container (max-width: 460px) { + width: 44px; + height: 44px; + min-width: 44px; + } +} + +.compactImage { + position: absolute; + inset: 0; + background-size: cover; + background-position: center; +} + +.compactDateBadge { + position: absolute; + inset: 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + background: rgba(0, 0, 0, 0.32); + color: var(--mantine-color-white); + text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3); +} + +.compactDateDay { + font-size: 1rem; + font-weight: 700; + line-height: 1; + + @container (max-width: 460px) { + font-size: 0.8125rem; + } +} + +.compactDateMonth { + font-size: 0.625rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + margin-top: 2px; + + @container (max-width: 460px) { + font-size: 0.5625rem; + } +} + +.compactContent { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 4px; +} + +.compactPrimary { + display: flex; + align-items: center; + gap: 6px; + min-width: 0; +} + +.compactTitle { + font-size: 0.9375rem; + font-weight: 500; + color: var(--hi-text); + transition: color 0.12s ease; + @include mixins.ellipsis; + letter-spacing: -0.005em; +} + +.compactRecurringIcon { + display: inline-flex; + align-items: center; + justify-content: center; + color: var(--hi-color-text-muted); + flex-shrink: 0; +} + +.compactMeta { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 6px; + font-size: 0.75rem; + color: var(--hi-color-text-muted); + line-height: 1.3; +} + +.compactMetaItem { + white-space: nowrap; +} + +.compactLocation { + @include mixins.ellipsis; + max-width: 160px; +} + +.compactDot { + color: var(--hi-color-border); +} + +.compactStatus { + display: inline-flex; + align-items: center; + gap: 4px; + font-weight: 500; + white-space: nowrap; +} + +.compactStatusDot { + width: 5px; + height: 5px; + border-radius: 50%; + background: currentColor; + animation: pulse 1.5s ease-in-out infinite; +} + +.compactStatus-success { + color: var(--hi-color-success); +} + +.compactStatus-warning { + color: var(--hi-color-warning); +} + +.compactStatus-danger { + color: var(--hi-color-danger); +} + +.compactStatus-muted { + color: var(--hi-color-text-muted); +} + +.compactTickets { + font-weight: 500; + white-space: nowrap; +} + +.compactTickets-available { + color: var(--hi-color-success); +} + +.compactTickets-partial { + color: var(--hi-color-warning); +} + +.compactTickets-sold-out { + color: var(--hi-color-danger); +} + +.compactStats { + display: flex; + gap: 16px; + flex-shrink: 0; + + @container (max-width: 460px) { + gap: 0; + } +} + +.compactStat { + display: flex; + flex-direction: column; + align-items: flex-end; + min-width: 64px; + + @container (max-width: 460px) { + min-width: 0; + + &:not(:last-child) { + display: none; + } + } +} + +.compactStatValue { + font-size: 0.875rem; + font-weight: 500; + color: var(--hi-text); + line-height: 1.2; + letter-spacing: -0.01em; + white-space: nowrap; +} + +.compactStatLabel { + font-size: 0.625rem; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--hi-color-text-muted); + margin-top: 2px; + + @container (max-width: 460px) { + display: none; + } +} + +.compactMenuButton { + flex-shrink: 0; +} diff --git a/frontend/src/components/common/EventCard/index.tsx b/frontend/src/components/common/EventCard/index.tsx index 850b824290..f3ff284534 100644 --- a/frontend/src/components/common/EventCard/index.tsx +++ b/frontend/src/components/common/EventCard/index.tsx @@ -39,9 +39,10 @@ const placeholderGradients = [ interface EventCardProps { event: Event; + compact?: boolean; } -export function EventCard({event}: EventCardProps) { +export function EventCard({event, compact = false}: EventCardProps) { const navigate = useNavigate(); const [isDuplicateModalOpen, duplicateModal] = useDisclosure(false); const [eventId, setEventId] = useState(); @@ -78,18 +79,18 @@ export function EventCard({event}: EventCardProps) { const getStatusConfig = () => { if (event.status === 'ARCHIVED') { - return {label: t`Archived`, status: 'archived'}; + return {label: t`Archived`, status: 'archived', tone: 'muted'}; } if (event.lifecycle_status === 'ENDED') { - return {label: t`Ended`, status: 'ended'}; + return {label: t`Ended`, status: 'ended', tone: 'muted'}; } if (event.status === 'DRAFT') { - return {label: t`Draft`, status: 'draft'}; + return {label: t`Draft`, status: 'draft', tone: 'warning'}; } if (event.lifecycle_status === 'ONGOING') { - return {label: t`Live`, status: 'live', pulse: true}; + return {label: t`Live`, status: 'live', tone: 'success', pulse: true}; } - return {label: t`On Sale`, status: 'onsale'}; + return {label: t`On Sale`, status: 'onsale', tone: 'success'}; }; const getLocationText = () => { @@ -191,6 +192,96 @@ export function EventCard({event}: EventCardProps) { const shortDateTime = formatDateWithLocale(displayDate, 'shortDateTime', event.timezone); const relativeDateStr = relativeDate(displayDate); + if (compact) { + return ( + <> +
+ +
+
+
+ {dayOfMonth} + {monthShort} +
+
+ +
+
+ {event.title} + {isRecurring && ( + + + + )} +
+
+ + {statusConfig.pulse && } + {statusConfig.label} + + · + + {shortDateTime} + + · + {relativeDateStr} + {locationText && ( + <> + · + + {locationText} + + + )} + {ticketAvailability && ( + <> + · + + {ticketAvailability.text} + + + )} +
+
+ +
+
+ {formatNumber(attendees)} + {t`Attendees`} +
+
+ {formatCurrency(revenue, event?.currency)} + {t`Revenue`} +
+
+ +
e.preventDefault()}> + + + + } + /> +
+ +
+ {isDuplicateModalOpen && } + + ); + } + return ( <> diff --git a/frontend/src/components/common/KpiGrid/KpiGrid.module.scss b/frontend/src/components/common/KpiGrid/KpiGrid.module.scss new file mode 100644 index 0000000000..ab9804c46f --- /dev/null +++ b/frontend/src/components/common/KpiGrid/KpiGrid.module.scss @@ -0,0 +1,100 @@ +@use "../../../styles/mixins.scss"; + +.grid { + background-color: var(--hi-color-border); + border: 0.5px solid var(--hi-color-border); + border-radius: var(--hi-radius-card); + overflow: hidden; + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 0.5px; + + @include mixins.respond-below(lg) { + grid-template-columns: repeat(2, 1fr); + } + + @include mixins.respond-below(sm) { + grid-template-columns: 1fr; + } +} + +.cell { + background-color: var(--hi-color-white); + padding: 16px; + display: flex; + flex-direction: column; +} + +.label { + font-size: 0.6875rem; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--hi-color-text-muted); + font-weight: 500; + margin-bottom: 8px; +} + +.middleRow { + display: flex; + justify-content: space-between; + align-items: baseline; + gap: 8px; +} + +.value { + font-size: 1.25rem; + font-weight: 500; + letter-spacing: -0.015em; + color: var(--hi-text); + line-height: 1.2; +} + +.sparkline { + width: 56px; + height: 20px; + flex-shrink: 0; +} + +.sparklineSpacer { + width: 56px; + height: 20px; + flex-shrink: 0; +} + +.delta { + margin-top: 8px; + font-size: 0.75rem; + display: inline-flex; + align-items: center; + gap: 2px; + line-height: 1.2; +} + +.deltaUp { + color: var(--hi-color-success); +} + +.deltaDown { + color: var(--hi-color-danger); +} + +.deltaFlat { + color: var(--hi-color-text-muted); +} + +.deltaNone { + color: var(--hi-color-text-muted); +} + +.deltaPlaceholder { + display: block; + margin-top: 8px; + height: 15px; +} + +.skeletonRow { + display: flex; + justify-content: space-between; + align-items: center; + gap: 8px; +} diff --git a/frontend/src/components/common/KpiGrid/index.tsx b/frontend/src/components/common/KpiGrid/index.tsx new file mode 100644 index 0000000000..ca0ee92db8 --- /dev/null +++ b/frontend/src/components/common/KpiGrid/index.tsx @@ -0,0 +1,100 @@ +import {ReactNode} from "react"; +import {Skeleton} from "@mantine/core"; +import {Sparkline} from "@mantine/charts"; +import {IconArrowDownRight, IconArrowUpRight} from "@tabler/icons-react"; +import {t} from "@lingui/macro"; +import classes from "./KpiGrid.module.scss"; + +interface KpiGridProps { + children: ReactNode; + className?: string; +} + +export const KpiGrid = ({children, className = ''}: KpiGridProps) => { + return ( +
+ {children} +
+ ); +}; + +export interface KpiCellDelta { + percent: number; + trend: 'up' | 'down' | 'flat'; +} + +interface KpiCellProps { + label: string; + value: string | number; + sparkline?: number[]; + delta?: KpiCellDelta | null; + isLoading?: boolean; +} + +const formatPercent = (percent: number, sign: '+' | '-') => { + const abs = Math.abs(percent).toFixed(1); + return `${sign}${abs}%`; +}; + +export const KpiCell = ({label, value, sparkline, delta, isLoading = false}: KpiCellProps) => { + if (isLoading) { + return ( +
+
{label}
+
+ + +
+ +
+ ); + } + + let deltaContent: ReactNode; + if (delta == null) { + deltaContent =