From c0a23519f56ee0bd45dcd306251ea5f064e35928 Mon Sep 17 00:00:00 2001 From: romanetar Date: Wed, 21 Oct 2026 16:42:58 +0200 Subject: [PATCH 1/4] fix(registration-stats): backfill SummitAttendeeTicket.SummitID from the order for tickets missing it --- .../model/Version20261021120000.php | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 database/migrations/model/Version20261021120000.php diff --git a/database/migrations/model/Version20261021120000.php b/database/migrations/model/Version20261021120000.php new file mode 100644 index 000000000..bc394817e --- /dev/null +++ b/database/migrations/model/Version20261021120000.php @@ -0,0 +1,36 @@ +addSql($sql); + } + + public function down(Schema $schema): void + { + } +} From 0e8e3f285e265176b490efce1e530a75462226e4 Mon Sep 17 00:00:00 2001 From: romanetar Date: Wed, 21 Oct 2026 16:42:59 +0200 Subject: [PATCH 2/4] fix(registration-stats): filter ticket queries on SummitAttendeeTicket.SummitID and compute the histogram once Replace SummitOrder.SummitID with SummitAttendeeTicket.SummitID (indexed) in the ticket and refund queries and drop the SummitOrder join where it only carried the summit filter. getTotalOrdersCount still counts orders, so it keeps its filter. getPurchasedTicketsGroupedBy no longer wraps the histogram in COUNT(*) to get the total: the buckets are counted and paged in PHP. Document that the histogram uses a single fixed summit offset (known limitation, behavior unchanged). --- .../Summit/SummitRegistrationStats.php | 59 ++++++++----------- 1 file changed, 23 insertions(+), 36 deletions(-) diff --git a/app/Models/Foundation/Summit/SummitRegistrationStats.php b/app/Models/Foundation/Summit/SummitRegistrationStats.php index 37aad51aa..e0d423567 100644 --- a/app/Models/Foundation/Summit/SummitRegistrationStats.php +++ b/app/Models/Foundation/Summit/SummitRegistrationStats.php @@ -50,6 +50,12 @@ private static function getDefaultTimeZoneOffset(?DateTime $now = null){ return $defaultSSTimeZone->getOffset($now) / 3600; } + /** + * Known limitation: a single fixed offset is used for the whole summit, the one in effect on its begin date. + * Tickets bought under a different offset (i.e. across a DST change) can fall in the neighbouring + * hour/day bucket of the purchased tickets histogram. Kept as is on purpose, see ClickUp task 86bcjg0u6. + * @return float|int + */ private function getTimeZoneOffset(){ try { $tz = $this->getTimeZone(); @@ -105,8 +111,7 @@ public function getActiveTicketsCount(?DateTime $startDate = null, ?DateTime $e try { $sql = <<prepareRawSQL($count_sql, ['summit_id' => $this->id]); - $res = $stmt->executeQuery(); - $res = $res->fetchFirstColumn(); - - $total = count($res) > 0 ? intval($res[0]) : 0; - - $sql .= " LIMIT {$pagingInfo->getPerPage()} OFFSET {$pagingInfo->getOffset()} "; Log::debug(sprintf("Summit::getTicketsPurchasedGroupedBy sql %s", $sql)); + // the histogram is computed once; the total is the number of buckets and the page is sliced here $stmt = $this->prepareRawSQL($sql, ['summit_id' => $this->id]); $res = $stmt->executeQuery(); - $res = $res->fetchAllAssociative(); - $res = count($res) > 0 ? $res : []; + $buckets = $res->fetchAllAssociative(); + + $total = count($buckets); + $res = array_slice($buckets, $pagingInfo->getOffset(), $pagingInfo->getPerPage()); return new PagingResponse ( From 0def745acc1d8191f076e395281448ce4ac67392 Mon Sep 17 00:00:00 2001 From: romanetar Date: Wed, 21 Oct 2026 16:42:59 +0200 Subject: [PATCH 3/4] feat(registration-stats): cache the registration stats response per summit and date range The lifetime comes from cache_api_response.registration_stats_response_lifetime (CACHE_API_RESPONSE_REGISTRATION_STATS_LIFETIME, default 600). A value of 0 disables the cache. --- .env.example | 3 ++ .../SummitRegistrationStatsSerializer.php | 41 +++++++++++++++++++ config/cache_api_response.php | 1 + 3 files changed, 45 insertions(+) diff --git a/.env.example b/.env.example index af492e576..20415d125 100644 --- a/.env.example +++ b/.env.example @@ -288,3 +288,6 @@ PAYMENTS_SERVICE_OAUTH2_SCOPES=payment-profile/read L5_SWAGGER_OPEN_API_SPEC_VERSION=3.1.0 SCHEDULER_OUTPUT_TO_STDOUT=false + +# registration stats response cache lifetime in seconds (0 disables the cache) +CACHE_API_RESPONSE_REGISTRATION_STATS_LIFETIME=600 diff --git a/app/ModelSerializers/Summit/SummitRegistrationStatsSerializer.php b/app/ModelSerializers/Summit/SummitRegistrationStatsSerializer.php index 7f148b63b..2e6bb1e58 100644 --- a/app/ModelSerializers/Summit/SummitRegistrationStatsSerializer.php +++ b/app/ModelSerializers/Summit/SummitRegistrationStatsSerializer.php @@ -13,6 +13,8 @@ **/ use App\Http\Exceptions\HTTP403ForbiddenException; use App\Utils\FilterUtils; +use Illuminate\Support\Facades\Cache; +use Illuminate\Support\Facades\Config; use Illuminate\Support\Facades\Log; use libs\utils\JsonUtils; use models\summit\Summit; @@ -36,6 +38,45 @@ public function serialize($expand = null, array $fields = [], array $relations = { $summit = $this->object; if (!$summit instanceof Summit) return []; + + $ttl = intval(Config::get('cache_api_response.registration_stats_response_lifetime', 600)); + // the endpoints are admin only, so the key does not depend on the caller + if ($ttl <= 0) + return $this->buildValues($summit, $expand, $fields, $relations, $params); + + $filter = $params['filter'] ?? null; + $start_date = null; + $end_date = null; + if ($filter instanceof Filter) list($start_date, $end_date) = FilterUtils::parseDateRangeUTC($filter); + + $key = sprintf + ( + 'registration_stats_%s_%s_%s_%s', + $summit->getId(), + $start_date?->getTimestamp() ?? 'null', + $end_date?->getTimestamp() ?? 'null', + md5(json_encode([$expand, $fields, $relations])) + ); + + $values = Cache::get($key); + if (!is_array($values)) { + $values = $this->buildValues($summit, $expand, $fields, $relations, $params); + Cache::put($key, $values, $ttl); + } + return $values; + } + + /** + * @param Summit $summit + * @param null $expand + * @param array $fields + * @param array $relations + * @param array $params + * @return array + * @throws \Exception + */ + private function buildValues(Summit $summit, $expand, array $fields, array $relations, array $params): array + { $values = parent::serialize($expand, $fields, $relations, $params); $filter = $params['filter'] ?? null; diff --git a/config/cache_api_response.php b/config/cache_api_response.php index cb728400e..6ded09216 100644 --- a/config/cache_api_response.php +++ b/config/cache_api_response.php @@ -18,6 +18,7 @@ 'get_event_feedback_response_lifetime' => env('CACHE_API_RESPONSE_GET_EVENT_FEEDBACK_LIFETIME', 600), 'get_published_event_response_lifetime' => env('CACHE_API_RESPONSE_GET_PUBLISHED_EVENT_LIFETIME', 600), 'get_summits_response_lifetime' => env('CACHE_API_RESPONSE_GET_SUMMITS_LIFETIME', 600), + 'registration_stats_response_lifetime' => env('CACHE_API_RESPONSE_REGISTRATION_STATS_LIFETIME', 600), 'file_url_lifetime' => env('CACHE_API_RESPONSE_FILE_URL_LIFETIME', 3600), 'event_has_access_lifetime' => env('CACHE_API_RESPONSE_EVENT_HAS_ACCESS_LIFETIME', 600), ]; \ No newline at end of file From 0fc1b2411f1bf79cd2b87d65553775e9e15e9ec9 Mon Sep 17 00:00:00 2001 From: romanetar Date: Wed, 21 Oct 2026 16:42:59 +0200 Subject: [PATCH 4/4] test(registration-stats): cover payload equivalence with the order join and the response cache --- .../RegistrationStatsCacheTest.php | 152 ++++++++++++++ .../RegistrationStatsPayloadTest.php | 189 ++++++++++++++++++ 2 files changed, 341 insertions(+) create mode 100644 tests/Repositories/RegistrationStatsCacheTest.php create mode 100644 tests/Repositories/RegistrationStatsPayloadTest.php diff --git a/tests/Repositories/RegistrationStatsCacheTest.php b/tests/Repositories/RegistrationStatsCacheTest.php new file mode 100644 index 000000000..ba5cd1fa6 --- /dev/null +++ b/tests/Repositories/RegistrationStatsCacheTest.php @@ -0,0 +1,152 @@ +current_group = IGroup::TrackChairs; + parent::setUp(); + self::insertSummitTestData(); + self::InsertOrdersTestData(); + + $this->store = []; + $this->ttls = []; + // a stateful fake keeps the test off redis; clearing $store stands for the entry expiring + Cache::shouldReceive('put')->andReturnUsing(function ($key, $value, $ttl = null) { + $this->store[$key] = $value; + $this->ttls[] = $ttl; + return true; + }); + Cache::shouldReceive('get')->andReturnUsing(function ($key, $default = null) { + return $this->store[$key] ?? $default; + }); + } + + protected function tearDown(): void + { + Mockery::close(); + self::clearOrdersTestData(); + self::clearSummitTestData(); + parent::tearDown(); + } + + private function filter(?int $start = null, ?int $end = null): Filter + { + if (is_null($start)) return new Filter(); + $expression = ["start_date>=$start"]; + if (!is_null($end)) $expression[] = "end_date<=$end"; + return FilterParser::parse($expression, ['start_date' => ['>='], 'end_date' => ['<=']]); + } + + /** + * @return array [payload, number of queries the request ran] + */ + private function request(?Filter $filter = null): array + { + $serializer = SerializerRegistry::getInstance() + ->getSerializer(self::$summit, SerializerRegistry::SerializerType_Admin_Registration_Stats); + QueryTimingCollector::reset(); + $payload = $serializer->serialize(null, [], [], ['filter' => $filter ?? new Filter()]); + return [$payload, QueryTimingCollector::$count]; + } + + public function testSecondRequestInsideTheLifetimeDoesNotRunTheQueries(): void + { + Config::set(self::Lifetime, 600); + + [$first, $first_queries] = $this->request(); + [$second, $second_queries] = $this->request(); + + $this->assertGreaterThan(0, $first_queries, 'the first request must run the stats queries'); + $this->assertSame(0, $second_queries); + $this->assertSame($first, $second); + $this->assertSame([600], $this->ttls); + } + + public function testRequestAfterTheLifetimeRunsTheQueriesAgain(): void + { + Config::set(self::Lifetime, 600); + + $this->request(); + $this->store = []; // the entry expired + + [, $queries] = $this->request(); + + $this->assertGreaterThan(0, $queries); + $this->assertCount(2, $this->ttls); + } + + public function testZeroLifetimeDisablesTheCache(): void + { + Config::set(self::Lifetime, 0); + + [, $first_queries] = $this->request(); + [, $second_queries] = $this->request(); + + $this->assertGreaterThan(0, $first_queries); + $this->assertSame($first_queries, $second_queries); + $this->assertSame([], $this->ttls, 'nothing is stored when the cache is off'); + } + + public function testDifferentDateRangesDoNotShareAnEntry(): void + { + Config::set(self::Lifetime, 600); + $start = time() - 86400; + + [, $unfiltered] = $this->request(); + [, $filtered] = $this->request($this->filter($start, $start + 3600)); + [, $filtered_again] = $this->request($this->filter($start, $start + 3600)); + [, $other_range] = $this->request($this->filter($start, $start + 7200)); + + $this->assertGreaterThan(0, $unfiltered); + $this->assertGreaterThan(0, $filtered, 'a date range must not read the unfiltered entry'); + $this->assertSame(0, $filtered_again); + $this->assertGreaterThan(0, $other_range); + $this->assertCount(3, $this->store); + } +} diff --git a/tests/Repositories/RegistrationStatsPayloadTest.php b/tests/Repositories/RegistrationStatsPayloadTest.php new file mode 100644 index 000000000..12ea5de6a --- /dev/null +++ b/tests/Repositories/RegistrationStatsPayloadTest.php @@ -0,0 +1,189 @@ +current_group = IGroup::TrackChairs; + parent::setUp(); + self::insertSummitTestData(); + self::InsertOrdersTestData(); + + self::$summit->setBeginDate(new DateTime(self::SummitBeginDate, new DateTimeZone('UTC'))); + self::$em->flush(); + + foreach (self::$summit_orders as $order) { + $this->ticket_ids[] = $order->getTickets()->first()->getId(); + } + + // [0] inactive, [2] bought at 23:30 local on Jul 15, [3] bought 23:30 local on the day DST ends + DB::table('SummitAttendeeTicket')->where('ID', $this->ticket_ids[0])->update(['IsActive' => 0]); + DB::table('SummitAttendeeTicket')->whereIn('ID', array_slice($this->ticket_ids, 1)) + ->update(['TicketBoughtDate' => '2026-07-20 15:00:00']); + DB::table('SummitAttendeeTicket')->where('ID', $this->ticket_ids[2]) + ->update(['TicketBoughtDate' => '2026-07-16 04:30:00']); + DB::table('SummitAttendeeTicket')->where('ID', $this->ticket_ids[3]) + ->update(['TicketBoughtDate' => '2026-11-02 05:30:00']); + + $id = self::$summit->getId(); + self::$em->clear(); + $this->stats_summit = self::$em->find(Summit::class, $id); + } + + protected function tearDown(): void + { + self::clearOrdersTestData(); + self::clearSummitTestData(); + parent::tearDown(); + } + + private function legacyScalar(string $sql): int + { + $rows = DB::connection('model')->select($sql, ['summit_id' => $this->stats_summit->getId()]); + return intval(array_values((array)$rows[0])[0]); + } + + public function testTicketCountsMatchTheOrderJoin(): void + { + $join = 'FROM SummitAttendeeTicket t INNER JOIN SummitOrder o ON o.ID = t.OrderID WHERE o.SummitID = :summit_id'; + + $this->assertSame(9, $this->stats_summit->getActiveTicketsCount()); + $this->assertSame( + $this->legacyScalar("SELECT COUNT(t.ID) $join AND t.IsActive = 1 AND t.Status = 'Paid'"), + $this->stats_summit->getActiveTicketsCount() + ); + $this->assertSame(1, $this->stats_summit->getInactiveTicketsCount()); + $this->assertSame( + $this->legacyScalar("SELECT COUNT(t.ID) $join AND t.IsActive = 0 AND t.Status = 'Paid'"), + $this->stats_summit->getInactiveTicketsCount() + ); + $this->assertSame( + $this->legacyScalar( + "SELECT COUNT(t.ID) FROM SummitAttendeeTicket t INNER JOIN SummitOrder o ON o.ID = t.OrderID + INNER JOIN SummitAttendee a ON a.ID = t.OwnerID + WHERE o.SummitID = :summit_id AND t.IsActive = 1 AND t.Status = 'Paid'" + ), + $this->stats_summit->getActiveAssignedTicketsCount() + ); + } + + public function testTicketsPerTypeMatchTheOrderJoin(): void + { + $legacy = DB::connection('model')->select( + "SELECT tt.Name AS type, COUNT(t.ID) AS qty FROM SummitAttendeeTicket t + INNER JOIN SummitOrder o ON o.ID = t.OrderID + INNER JOIN SummitTicketType tt ON t.TicketTypeID = tt.ID + WHERE o.SummitID = :summit_id AND t.IsActive = 1 AND t.Status = 'Paid' GROUP BY tt.Name", + ['summit_id' => $this->stats_summit->getId()] + ); + $legacy = array_map(fn($r) => ['type' => $r->type, 'qty' => intval($r->qty)], $legacy); + $current = array_map( + fn($r) => ['type' => $r['type'], 'qty' => intval($r['qty'])], + $this->stats_summit->getActiveTicketsCountPerTicketType() + ); + + $this->assertSame([['type' => 'TICKET TYPE 1', 'qty' => 9]], $current); + $this->assertSame($legacy, $current); + } + + /** + * Same buckets as the query that joined SummitOrder, including the ticket bought at 23:30 local + * time (still Jul 15 although it is already Jul 16 in UTC). + */ + public function testPurchasedTicketsHistogramMatchesTheOrderJoin(): void + { + $offset = self::LegacyOffset; + $legacy = DB::connection('model')->select( + "SELECT COUNT(t.ID) AS qty, DATE_FORMAT(CONVERT_TZ(t.TicketBoughtDate,'+00:00','{$offset}'), '%b %D') AS label + FROM SummitAttendeeTicket t INNER JOIN SummitOrder o ON o.ID = t.OrderID + WHERE o.SummitID = :summit_id AND t.IsActive = 1 AND t.Status = 'Paid' + GROUP BY DATE_FORMAT(CONVERT_TZ(t.TicketBoughtDate,'+00:00','{$offset}'), '%b %D') + ORDER BY t.TicketBoughtDate ASC", + ['summit_id' => $this->stats_summit->getId()] + ); + $legacy = array_map(fn($r) => ['qty' => intval($r->qty), 'label' => $r->label], $legacy); + + $response = $this->stats_summit->getPurchasedTicketsGroupedBy(IStatsConstants::GroupByDay, new PagingInfo(1, 100)); + $current = array_map( + fn($r) => ['qty' => intval($r['qty']), 'label' => $r['label']], + $response->getItems() + ); + + $this->assertSame($legacy, $current); + $this->assertSame(count($legacy), $response->getTotal()); + $this->assertSame('Jul 15th', $current[0]['label'], 'the 23:30 local purchase belongs to Jul 15'); + $this->assertSame(1, $current[0]['qty']); + } + + /** + * Pins the known limitation documented on getTimeZoneOffset(): one fixed offset for the whole + * summit. The ticket bought at 23:30 local time on Nov 1 (UTC-06:00 after DST ended) is bucketed + * with the July offset (UTC-05:00) and lands on Nov 2. Not a bug of this change; if the offset + * is ever fixed, this expectation changes with it. + */ + public function testHistogramKeepsTheFixedSummitOffsetAcrossDst(): void + { + $response = $this->stats_summit->getPurchasedTicketsGroupedBy(IStatsConstants::GroupByDay, new PagingInfo(1, 100)); + $labels = array_column($response->getItems(), 'label'); + + $this->assertContains('Nov 2nd', $labels); + $this->assertNotContains('Nov 1st', $labels); + } + + public function testHistogramPaginatesTheBucketsInMemory(): void + { + $page1 = $this->stats_summit->getPurchasedTicketsGroupedBy(IStatsConstants::GroupByDay, new PagingInfo(1, 2)); + $page2 = $this->stats_summit->getPurchasedTicketsGroupedBy(IStatsConstants::GroupByDay, new PagingInfo(2, 2)); + + $this->assertSame(3, $page1->getTotal()); + $this->assertSame(3, $page2->getTotal()); + $this->assertSame(['Jul 15th', 'Jul 20th'], array_column($page1->getItems(), 'label')); + $this->assertSame(['Nov 2nd'], array_column($page2->getItems(), 'label')); + $this->assertSame(2, $page1->getLastPage()); + } +}