diff --git a/app/Http/Controllers/IncidentController.php b/app/Http/Controllers/IncidentController.php index 239d0ff..8939ff6 100644 --- a/app/Http/Controllers/IncidentController.php +++ b/app/Http/Controllers/IncidentController.php @@ -19,6 +19,7 @@ use Illuminate\Http\Exceptions\HttpResponseException; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; +use Illuminate\Pagination\LengthAwarePaginator; use Illuminate\Support\Facades\Log; use Illuminate\Routing\Controller; use Symfony\Component\HttpFoundation\ResponseHeaderBag; @@ -28,12 +29,17 @@ class IncidentController extends Controller { use CacheableResponse; + /** + * Upper bound for `limit` on the paginated incident endpoints. + */ + private const MAX_LIMIT = 1000; + public function active(Request $request): JsonResponse { $concelho = $this->concelhoFilter($request); $subRegion = $request->get('subRegion'); - $limit = $request->exists('limit') ? (int) $request->get('limit') : 1000; + $limit = $this->limit($request, 1000); $geoJson = $request->boolean('geojson'); @@ -43,7 +49,8 @@ public function active(Request $request): JsonResponse $incidents = $this->buildActiveQuery($request) ->when($concelho, fn ($q) => $q->whereIn('concelho', $concelho)) ->when($subRegion, fn ($q) => $q->where('sub_regiao', $subRegion)) - ->orderBy('created_at', 'desc') + ->orderBy('created', 'desc') + ->orderBy('_id', 'desc') ->paginate($limit); if($csv) { @@ -88,7 +95,8 @@ public function active(Request $request): JsonResponse $incidents = $this->buildActiveQuery($request) ->when($concelho, fn ($q) => $q->whereIn('concelho', $concelho)) - ->orderBy('created_at', 'desc') + ->orderBy('created', 'desc') + ->orderBy('_id', 'desc') ->paginate($limit); } @@ -126,7 +134,8 @@ public function active(Request $request): JsonResponse } } else { $incident = Incident::isFire() - ->orderBy('created_at', 'desc') + ->orderBy('created', 'desc') + ->orderBy('_id', 'desc') ->paginate(1); @@ -219,6 +228,7 @@ public function active(Request $request): JsonResponse $response = new JsonResponse([ 'success' => true, + 'paginator' => $this->paginatorMeta($incidents), 'data' => IncidentResource::collection($incidents), ]); @@ -235,11 +245,12 @@ public function activeKML(Request $request): JsonResponse { $concelho = $this->concelhoFilter($request); - $limit = $request->exists('limit') ? (int) $request->get('limit') : 300; + $limit = $this->limit($request, 300); $incidents = $this->buildActiveQuery($request) ->when($concelho, fn ($q) => $q->whereIn('concelho', $concelho)) - ->orderBy('created_at', 'desc') + ->orderBy('created', 'desc') + ->orderBy('_id', 'desc') ->paginate($limit); $r=new \XMLWriter(); @@ -274,6 +285,34 @@ public function activeKML(Request $request): JsonResponse die(); } + /** + * Page size from `limit`: `$default` when absent or not an integer, + * otherwise clamped to [1, MAX_LIMIT]. + */ + private function limit(Request $request, int $default): int + { + $limit = filter_var($request->query('limit'), FILTER_VALIDATE_INT); + + if ($limit === false) { + return $default; + } + + return min(self::MAX_LIMIT, max(1, $limit)); + } + + /** + * Pagination metadata shared by the paginated v2 incident endpoints. + */ + private function paginatorMeta(LengthAwarePaginator $paginator): array + { + return [ + 'currentPage' => $paginator->currentPage(), + 'totalPages' => $paginator->lastPage(), + 'totalItems' => $paginator->total(), + 'perPage' => $paginator->perPage(), + ]; + } + /** * Values to match for the optional `concelho` filter: what the client sent * plus the casing the ingestion jobs store ("Vila Nova De Gaia"). Null when @@ -368,11 +407,7 @@ public function search(IncidentSearchRequest $request) $naturezaCode = $request->get('naturezaCode'); - if($request->exists('limit')){ - $limit = (int)$request->get('limit'); - } else { - $limit = 50; - } + $limit = $this->limit($request, 50); if($day && ($before || $after)){ abort(422); @@ -426,7 +461,7 @@ public function search(IncidentSearchRequest $request) return $query->where('naturezaCode', (string)$naturezaCode); })->when($subRegion, function ($query, $subRegion){ return $query->where('sub_regiao', (string)$subRegion); - }); + })->orderBy('dateTime', 'desc')->orderBy('_id', 'desc'); $csv2 = $request->get('csv2'); @@ -477,15 +512,9 @@ public function search(IncidentSearchRequest $request) } else { $incidents = $incidents->paginate($limit); - $paginator = [ - 'currentPage' => $incidents->currentPage(), - 'totalPages' => $incidents->lastPage(), - 'totalItems' => $incidents->total() - ]; - return new JsonResponse([ 'success' => true, - 'paginator' => $paginator, + 'paginator' => $this->paginatorMeta($incidents), 'data' => IncidentResource::collection($incidents), ]); } @@ -529,25 +558,17 @@ public function kmlVost(Request $request, $id) public function burnMoreThan1000(Request $request) { - if($request->exists('limit')){ - $limit = (int)$request->get('limit'); - } else { - $limit = 500; - } + $limit = $this->limit($request, 500); $incidents = Incident::where('isFire', true) ->where('icnf.burnArea.total', '>', 1000) + ->orderBy('dateTime', 'desc') + ->orderBy('_id', 'desc') ->paginate($limit); - $paginator = [ - 'currentPage' => $incidents->currentPage(), - 'totalPages' => $incidents->lastPage(), - 'totalItems' => $incidents->total() - ]; - return new JsonResponse([ 'success' => true, - 'paginator' => $paginator, + 'paginator' => $this->paginatorMeta($incidents), 'data' => IncidentResource::collection($incidents), ]); } diff --git a/app/Http/Controllers/IncidentPhotoController.php b/app/Http/Controllers/IncidentPhotoController.php index 1319b89..86ef375 100644 --- a/app/Http/Controllers/IncidentPhotoController.php +++ b/app/Http/Controllers/IncidentPhotoController.php @@ -226,6 +226,7 @@ public function latest(Request $request): JsonResponse $photos = IncidentPhoto::where('status', IncidentPhoto::STATUS_APPROVED) ->where('public', true) ->orderBy('created_at', 'desc') + ->orderBy('_id', 'desc') ->paginate($perPage); return new JsonResponse([ @@ -235,6 +236,7 @@ public function latest(Request $request): JsonResponse 'total' => $photos->total(), 'page' => $photos->currentPage(), 'per_page' => $photos->perPage(), + 'last_page' => $photos->lastPage(), ], ], 200, ['Cache-Control' => 'public, s-maxage=120, max-age=60, stale-while-revalidate=300']); } @@ -248,6 +250,7 @@ public function publicList(Request $request, string $id): JsonResponse ->where('status', IncidentPhoto::STATUS_APPROVED) ->where('public', true) ->orderBy('created_at', 'desc') + ->orderBy('_id', 'desc') ->paginate($perPage); return new JsonResponse([ @@ -257,6 +260,7 @@ public function publicList(Request $request, string $id): JsonResponse 'total' => $photos->total(), 'page' => $photos->currentPage(), 'per_page' => $photos->perPage(), + 'last_page' => $photos->lastPage(), ], ], 200, ['Cache-Control' => 'public, s-maxage=300, max-age=120, stale-while-revalidate=300']); } @@ -272,6 +276,7 @@ public function listAll(Request $request, string $id): JsonResponse $perPage = min(200, max(1, (int) $request->get('per_page', 50))); $photos = IncidentPhoto::where('fire_id', $id) ->orderBy('created_at', 'desc') + ->orderBy('_id', 'desc') ->paginate($perPage); return new JsonResponse([ @@ -281,6 +286,7 @@ public function listAll(Request $request, string $id): JsonResponse 'total' => $photos->total(), 'page' => $photos->currentPage(), 'per_page' => $photos->perPage(), + 'last_page' => $photos->lastPage(), ], ]); } diff --git a/app/Http/Controllers/PhotoModerationController.php b/app/Http/Controllers/PhotoModerationController.php index 18dd2d5..5444184 100644 --- a/app/Http/Controllers/PhotoModerationController.php +++ b/app/Http/Controllers/PhotoModerationController.php @@ -26,6 +26,7 @@ public function index(Request $request): JsonResponse $photos = IncidentPhoto::where('status', $status) ->orderBy('created_at', 'asc') + ->orderBy('_id', 'asc') ->paginate($perPage); return new JsonResponse([ @@ -35,6 +36,7 @@ public function index(Request $request): JsonResponse 'total' => $photos->total(), 'page' => $photos->currentPage(), 'per_page' => $photos->perPage(), + 'last_page' => $photos->lastPage(), ], ]); } diff --git a/database/migrations/2026_10_26_180000_add_search_sort_indexes.php b/database/migrations/2026_10_26_180000_add_search_sort_indexes.php new file mode 100644 index 0000000..291f55e --- /dev/null +++ b/database/migrations/2026_10_26_180000_add_search_sort_indexes.php @@ -0,0 +1,57 @@ + 1, 'dateTime' => 1, '_id' => 1], 'fire_datetime_id_idx'], + [['dateTime' => 1, '_id' => 1], 'datetime_id_idx'], + ]; + } + + public function up(): void + { + $collection = DB::connection('mongodb')->getMongoDB()->selectCollection('data'); + + foreach ($this->indexes() as [$keys, $name]) { + try { + $collection->createIndex($keys, ['name' => $name]); + } catch (CommandException $e) { + // 85 = IndexOptionsConflict, 86 = IndexKeySpecsConflict: an + // equivalent index already exists under another name. + if (in_array($e->getCode(), [85, 86], true)) { + continue; + } + + throw $e; + } + } + } + + public function down(): void + { + $collection = DB::connection('mongodb')->getMongoDB()->selectCollection('data'); + + foreach ($this->indexes() as [, $name]) { + try { + $collection->dropIndex($name); + } catch (CommandException $e) { + // 27 = IndexNotFound. + if ($e->getCode() === 27) { + continue; + } + + throw $e; + } + } + } +}; diff --git a/docs/api.yaml b/docs/api.yaml index 90efc39..d70f6cd 100644 --- a/docs/api.yaml +++ b/docs/api.yaml @@ -139,6 +139,8 @@ paths: $ref: './fires/paths/v2-incident-posit.yaml' /v2/incidents/{id}/photos: $ref: './fires/paths/v2-incident-photos.yaml' + /v2/incidents/{id}/photos/all: + $ref: './fires/paths/v2-incident-photos-all.yaml' /v2/incidents/{id}/live-activity/register: $ref: './fires/paths/v2-incident-liveactivity-register.yaml' /v2/incidents/{id}/live-activity/unregister: diff --git a/docs/fires/models/Paginator.yaml b/docs/fires/models/Paginator.yaml index 99a9933..3328f2b 100644 --- a/docs/fires/models/Paginator.yaml +++ b/docs/fires/models/Paginator.yaml @@ -1,9 +1,21 @@ title: Paginator type: object +description: >- + Pagination metadata of the `limit`/`page` incident endpoints. Results are + ordered deterministically, so walking pages 1..totalPages returns every + item exactly once (as long as the data does not change in between). properties: currentPage: type: integer + example: 1 totalPages: type: integer + description: Last page number (1 when there are no results). + example: 3 totalItems: type: integer + example: 128 + perPage: + type: integer + description: Effective page size after clamping `limit`. + example: 50 diff --git a/docs/fires/models/PhotoPageMeta.yaml b/docs/fires/models/PhotoPageMeta.yaml new file mode 100644 index 0000000..c2817fc --- /dev/null +++ b/docs/fires/models/PhotoPageMeta.yaml @@ -0,0 +1,18 @@ +title: PhotoPageMeta +type: object +description: Pagination metadata of the `per_page`/`page` photo endpoints. +properties: + total: + type: integer + example: 42 + page: + type: integer + example: 1 + per_page: + type: integer + description: Effective page size after clamping `per_page`. + example: 20 + last_page: + type: integer + description: Last page number (1 when there are no results). + example: 3 diff --git a/docs/fires/parameters/query/page.yaml b/docs/fires/parameters/query/page.yaml new file mode 100644 index 0000000..2f0ace1 --- /dev/null +++ b/docs/fires/parameters/query/page.yaml @@ -0,0 +1,10 @@ +name: page +in: query +required: false +description: >- + Page number, starting at 1. Values that are not a positive integer are + treated as 1. A page past the last one returns an empty `data` array. +schema: + type: integer + minimum: 1 + default: 1 diff --git a/docs/fires/paths/v2-1000ha-burned.yaml b/docs/fires/paths/v2-1000ha-burned.yaml index b2260b3..fae4df0 100644 --- a/docs/fires/paths/v2-1000ha-burned.yaml +++ b/docs/fires/paths/v2-1000ha-burned.yaml @@ -1,10 +1,22 @@ get: operationId: v2GetIncidentsBurnedMoreThan1000Ha summary: List fire incidents with ICNF-reported burn area exceeding 1000 ha + description: Paginated with `limit` and `page`, newest first (`dateTime` desc, then `_id`). tags: - V2 parameters: - - $ref: '../parameters/query/limit.yaml' + - name: limit + in: query + required: false + description: >- + Page size (1–1000, default 500). Values above 1000 are clamped to + 1000, below 1 to 1; a non-integer value uses the default. + schema: + type: integer + minimum: 1 + maximum: 1000 + default: 500 + - $ref: '../parameters/query/page.yaml' responses: 200: description: OK diff --git a/docs/fires/paths/v2-active-kml.yaml b/docs/fires/paths/v2-active-kml.yaml index 0c339be..d0b83ed 100644 --- a/docs/fires/paths/v2-active-kml.yaml +++ b/docs/fires/paths/v2-active-kml.yaml @@ -1,6 +1,10 @@ get: operationId: v2GetActiveIncidentsKML summary: Active fire incidents as a KML document + description: >- + Paginated with `limit` and `page`, newest first (`created` desc, then + `_id`). KML has no pagination metadata; use `/v2/incidents/active` for + the totals. tags: - V2 parameters: @@ -8,7 +12,18 @@ get: - $ref: '../parameters/query/fma.yaml' - $ref: '../parameters/query/otherFires.yaml' - $ref: '../parameters/query/concelho-v2.yaml' - - $ref: '../parameters/query/limit.yaml' + - name: limit + in: query + required: false + description: >- + Page size (1–1000, default 300). Values above 1000 are clamped to + 1000, below 1 to 1; a non-integer value uses the default. + schema: + type: integer + minimum: 1 + maximum: 1000 + default: 300 + - $ref: '../parameters/query/page.yaml' responses: 200: description: KML document with active incident placemarks diff --git a/docs/fires/paths/v2-active.yaml b/docs/fires/paths/v2-active.yaml index ca949ba..816efba 100644 --- a/docs/fires/paths/v2-active.yaml +++ b/docs/fires/paths/v2-active.yaml @@ -1,20 +1,35 @@ get: operationId: v2getFiresActiveList summary: List active fires + description: >- + Paginated with `limit` and `page`, newest first (`created` desc, then `_id`). + The `paginator` object is returned in the JSON response (not in the + `geojson` or CSV variants). tags: - V2 parameters: - $ref: '../parameters/query/all.yaml' - $ref: '../parameters/query/geojson.yaml' - $ref: '../parameters/query/concelho-v2.yaml' - - $ref: '../parameters/query/limit.yaml' + - name: limit + in: query + required: false + description: >- + Page size (1–1000, default 1000). Values above 1000 are clamped to + 1000, below 1 to 1; a non-integer value uses the default. + schema: + type: integer + minimum: 1 + maximum: 1000 + default: 1000 + - $ref: '../parameters/query/page.yaml' - $ref: '../parameters/query/fma.yaml' - $ref: '../parameters/query/otherFires.yaml' - $ref: '../parameters/query/csv.yaml' - $ref: '../parameters/query/csv2.yaml' responses: 200: - $ref: '../responses/legacy-fires-list.yaml' + $ref: '../responses/v2-fires-paginated-list.yaml' 404: $ref: '../../common/responses/404.yaml' 422: diff --git a/docs/fires/paths/v2-incident-photos-all.yaml b/docs/fires/paths/v2-incident-photos-all.yaml new file mode 100644 index 0000000..23db327 --- /dev/null +++ b/docs/fires/paths/v2-incident-photos-all.yaml @@ -0,0 +1,55 @@ +post: + operationId: v2ListAllIncidentPhotos + summary: List every photo of an incident (moderators) + description: | + All photos uploaded for the incident, whatever their status or + visibility, with the moderator payload (raw GPS / EXIF). Newest first. + Requires the `key` header to match `PHOTO_MODERATION_KEY`. `per_page` and + `page` may be sent as query string or form fields. + tags: + - Moderation + parameters: + - name: id + in: path + required: true + description: Incident public ID + schema: + type: string + - name: key + in: header + required: true + description: Moderation API key (`PHOTO_MODERATION_KEY` env value). + schema: + type: string + - name: per_page + in: query + required: false + description: Items per page (1–200, default 50). + schema: + type: integer + minimum: 1 + maximum: 200 + default: 50 + - $ref: '../parameters/query/page.yaml' + responses: + 200: + description: OK + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + data: + type: array + items: + type: object + meta: + $ref: '../models/PhotoPageMeta.yaml' + 401: + $ref: '../../common/responses/401.yaml' + 404: + $ref: '../../common/responses/404.yaml' + 500: + $ref: '../../common/responses/500.yaml' diff --git a/docs/fires/paths/v2-incident-photos.yaml b/docs/fires/paths/v2-incident-photos.yaml index cb683f0..c599e17 100644 --- a/docs/fires/paths/v2-incident-photos.yaml +++ b/docs/fires/paths/v2-incident-photos.yaml @@ -25,6 +25,7 @@ get: minimum: 1 maximum: 50 default: 20 + - $ref: '../parameters/query/page.yaml' responses: 200: description: OK @@ -79,14 +80,7 @@ get: nullable: true description: Azimuth (compass direction) the camera was pointing when the photo was taken. meta: - type: object - properties: - total: - type: integer - page: - type: integer - per_page: - type: integer + $ref: '../models/PhotoPageMeta.yaml' 404: $ref: '../../common/responses/404.yaml' 500: diff --git a/docs/fires/paths/v2-moderation-photos.yaml b/docs/fires/paths/v2-moderation-photos.yaml index ed734ad..26cc8c4 100644 --- a/docs/fires/paths/v2-moderation-photos.yaml +++ b/docs/fires/paths/v2-moderation-photos.yaml @@ -31,6 +31,7 @@ get: minimum: 1 maximum: 100 default: 25 + - $ref: '../parameters/query/page.yaml' responses: 200: description: OK @@ -46,14 +47,7 @@ get: items: type: object meta: - type: object - properties: - total: - type: integer - page: - type: integer - per_page: - type: integer + $ref: '../models/PhotoPageMeta.yaml' 401: $ref: '../../common/responses/401.yaml' 500: diff --git a/docs/fires/paths/v2-photos-latest.yaml b/docs/fires/paths/v2-photos-latest.yaml index f51f403..e30ccff 100644 --- a/docs/fires/paths/v2-photos-latest.yaml +++ b/docs/fires/paths/v2-photos-latest.yaml @@ -87,13 +87,6 @@ get: nullable: true description: Azimuth (compass direction) the camera was pointing when the photo was taken. meta: - type: object - properties: - total: - type: integer - page: - type: integer - per_page: - type: integer + $ref: '../models/PhotoPageMeta.yaml' 500: $ref: '../../common/responses/500.yaml' diff --git a/docs/fires/paths/v2-search.yaml b/docs/fires/paths/v2-search.yaml index 21f0024..758ab5a 100644 --- a/docs/fires/paths/v2-search.yaml +++ b/docs/fires/paths/v2-search.yaml @@ -1,6 +1,7 @@ get: operationId: V2SearchIncidents summary: Search incidents (fires and other ANEPC incidents) + description: Paginated with `limit` and `page`, newest first (`dateTime` desc, then `_id`). tags: - V2 parameters: @@ -10,9 +11,21 @@ get: - $ref: '../parameters/query/before.yaml' - $ref: '../parameters/query/extend.yaml' - $ref: '../parameters/query/concelho-v2.yaml' + - name: limit + in: query + required: false + description: >- + Page size (1–1000, default 50). Values above 1000 are clamped to + 1000, below 1 to 1; a non-integer value uses the default. Ignored with `csv2=1`, which exports every match. + schema: + type: integer + minimum: 1 + maximum: 1000 + default: 50 + - $ref: '../parameters/query/page.yaml' responses: 200: - $ref: '../responses/legacy-fires-list.yaml' + $ref: '../responses/v2-fires-paginated-list.yaml' 404: $ref: '../../common/responses/404.yaml' 422: diff --git a/docs/fires/responses/v2-fires-paginated-list.yaml b/docs/fires/responses/v2-fires-paginated-list.yaml new file mode 100644 index 0000000..2cd2e9f --- /dev/null +++ b/docs/fires/responses/v2-fires-paginated-list.yaml @@ -0,0 +1,14 @@ +description: OK +content: + application/json: + schema: + type: object + properties: + success: + type: boolean + paginator: + $ref: '../models/Paginator.yaml' + data: + type: array + items: + $ref: '../models/Fire.yaml' diff --git a/tests/Feature/Controllers/IncidentController/PaginationTest.php b/tests/Feature/Controllers/IncidentController/PaginationTest.php new file mode 100644 index 0000000..fa6113b --- /dev/null +++ b/tests/Feature/Controllers/IncidentController/PaginationTest.php @@ -0,0 +1,190 @@ +fire('1001', '2025-08-10 10:00', ['active' => true]); + $this->fire('1002', '2025-08-10 12:00', ['active' => true]); + $this->fire('1003', '2025-08-10 11:00', ['active' => true]); + + $page1 = $this->getJson('v2/incidents/active?limit=2') + ->assertOk() + ->assertJsonPath('paginator', [ + 'currentPage' => 1, + 'totalPages' => 2, + 'totalItems' => 3, + 'perPage' => 2, + ]); + $this->assertSame(['1002', '1003'], array_column($page1->json('data'), 'id')); + + $page2 = $this->getJson('v2/incidents/active?limit=2&page=2') + ->assertOk() + ->assertJsonPath('paginator.currentPage', 2); + $this->assertSame(['1001'], array_column($page2->json('data'), 'id')); + + $this->getJson('v2/incidents/active?limit=2&page=3') + ->assertOk() + ->assertJsonCount(0, 'data'); + } + + /** @test */ + public function limit_is_clamped_and_falls_back_to_the_default(): void + { + $this->fire('1001', '2025-08-10 10:00', ['active' => true]); + + $this->getJson('v2/incidents/active?limit=5000')->assertJsonPath('paginator.perPage', 1000); + $this->getJson('v2/incidents/active?limit=0')->assertJsonPath('paginator.perPage', 1); + $this->getJson('v2/incidents/active?limit=-5')->assertJsonPath('paginator.perPage', 1); + $this->getJson('v2/incidents/active?limit=abc')->assertJsonPath('paginator.perPage', 1000); + $this->getJson('v2/incidents/active')->assertJsonPath('paginator.perPage', 1000); + + $this->getJson('v2/incidents/search?limit=5000')->assertJsonPath('paginator.perPage', 1000); + $this->getJson('v2/incidents/search')->assertJsonPath('paginator.perPage', 50); + $this->getJson('v2/incidents/1000ha-burned')->assertJsonPath('paginator.perPage', 500); + } + + /** @test */ + public function search_pages_are_ordered_by_start_time_desc(): void + { + $this->fire('1001', '2025-08-10 10:00'); + $this->fire('1002', '2025-08-12 10:00'); + $this->fire('1003', '2025-08-11 10:00'); + + $page1 = $this->getJson('v2/incidents/search?after=2025-08-01&before=2025-08-31&limit=2') + ->assertOk() + ->assertJsonPath('paginator.totalItems', 3) + ->assertJsonPath('paginator.totalPages', 2); + $page2 = $this->getJson('v2/incidents/search?after=2025-08-01&before=2025-08-31&limit=2&page=2') + ->assertOk(); + + $this->assertSame( + ['1002', '1003', '1001'], + array_merge(array_column($page1->json('data'), 'id'), array_column($page2->json('data'), 'id')) + ); + } + + /** @test */ + public function search_ordering_is_served_by_an_index_without_an_in_memory_sort(): void + { + foreach (range(1, 20) as $i) { + $this->fire((string) (2000 + $i), '2025-08-01 10:00', ['isFire' => $i % 2 === 0]); + } + + $sort = ['dateTime' => -1, '_id' => -1]; + $range = ['$gte' => new UTCDateTime(Carbon::parse('2025-01-01')), '$lte' => new UTCDateTime(Carbon::parse('2025-12-31'))]; + + foreach ([ + 'fire-only' => ['isFire' => true], + 'fire-only in a range' => ['isFire' => true, 'dateTime' => $range], + 'all=1' => [], + 'all=1 in a range' => ['dateTime' => $range], + ] as $label => $filter) { + $stages = $this->planStages($filter, $sort); + + $this->assertContains('IXSCAN', $stages, $label); + $this->assertNotContains('SORT', $stages, $label); + } + } + + /** @test */ + public function burned_more_than_1000ha_is_ordered_by_start_time_desc(): void + { + $this->fire('1001', '2025-07-01 10:00', ['icnf' => ['burnArea' => ['total' => 1500]]]); + $this->fire('1002', '2025-08-01 10:00', ['icnf' => ['burnArea' => ['total' => 2500]]]); + $this->fire('1003', '2025-08-02 10:00', ['icnf' => ['burnArea' => ['total' => 10]]]); + + $response = $this->getJson('v2/incidents/1000ha-burned?limit=1') + ->assertOk() + ->assertJsonPath('paginator.totalItems', 2) + ->assertJsonPath('paginator.totalPages', 2); + $this->assertSame(['1002'], array_column($response->json('data'), 'id')); + + $response = $this->getJson('v2/incidents/1000ha-burned?limit=1&page=2')->assertOk(); + $this->assertSame(['1001'], array_column($response->json('data'), 'id')); + } + + /** @test */ + public function photo_listings_expose_the_last_page(): void + { + foreach (range(1, 3) as $i) { + IncidentPhoto::create([ + 'fire_id' => '1001', + 'status' => IncidentPhoto::STATUS_APPROVED, + 'public' => true, + 'storage_key' => "photos/{$i}.jpg", + ]); + } + + $this->getJson('v2/photos/latest?per_page=2') + ->assertOk() + ->assertJsonPath('meta', [ + 'total' => 3, + 'page' => 1, + 'per_page' => 2, + 'last_page' => 2, + ]); + } + + /** + * Stage names of the winning plan for a find with sort on `data`. + * + * @return array + */ + private function planStages(array $filter, array $sort): array + { + $explain = DB::connection('mongodb')->getMongoDB()->command([ + 'explain' => ['find' => 'data', 'filter' => (object) $filter, 'sort' => $sort, 'limit' => 50], + 'verbosity' => 'queryPlanner', + ], ['typeMap' => ['root' => 'array', 'document' => 'array', 'array' => 'array']])->toArray()[0]; + + $stages = []; + $walk = function (array $plan) use (&$walk, &$stages) { + $stages[] = $plan['stage']; + foreach (['inputStage', 'queryPlan'] as $key) { + if (isset($plan[$key])) { + $walk($plan[$key]); + } + } + foreach ($plan['inputStages'] ?? [] as $input) { + $walk($input); + } + }; + $walk($explain['queryPlanner']['winningPlan']); + + return $stages; + } + + private function fire(string $id, string $start, array $attributes = []): void + { + $dateTime = Carbon::parse($start, 'Europe/Lisbon'); + + IncidentFactory::new()->fire()->create(array_merge([ + 'id' => $id, + 'dateTime' => $dateTime, + 'date' => $dateTime->format('d-m-Y'), + 'hour' => $dateTime->format('H:i'), + ], $attributes)); + } +}