Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 52 additions & 31 deletions app/Http/Controllers/IncidentController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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');

Expand All @@ -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) {
Expand Down Expand Up @@ -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);
}

Expand Down Expand Up @@ -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);


Expand Down Expand Up @@ -219,6 +228,7 @@ public function active(Request $request): JsonResponse

$response = new JsonResponse([
'success' => true,
'paginator' => $this->paginatorMeta($incidents),
'data' => IncidentResource::collection($incidents),
]);

Expand All @@ -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();
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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');

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Add compound indexes for the new search ordering

For searches without a date restriction, this adds a blocking sort over the entire matching incident archive before returning even the first 50 records. The indexes defined in database/migrations/2026_05_25_120000_create_mongodb_indexes.php cover dateTime and (isFire, dateTime), but neither includes the new _id sort key; MongoDB therefore cannot obtain this complete ordering from those indexes (MongoDB documentation). Unlike the previous unsorted, limited query, each page now requires fetching and sorting all matches, creating a substantial latency and database-load regression on large archives. Add matching compound indexes for the default fire-only search and the all=1 search alongside this ordering change.

Useful? React with 👍 / 👎.


$csv2 = $request->get('csv2');

Expand Down Expand Up @@ -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),
]);
}
Expand Down Expand Up @@ -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),
]);
}
Expand Down
6 changes: 6 additions & 0 deletions app/Http/Controllers/IncidentPhotoController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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([
Expand All @@ -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']);
}
Expand All @@ -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([
Expand All @@ -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']);
}
Expand All @@ -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([
Expand All @@ -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(),
],
]);
}
Expand Down
2 changes: 2 additions & 0 deletions app/Http/Controllers/PhotoModerationController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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([
Expand All @@ -35,6 +36,7 @@ public function index(Request $request): JsonResponse
'total' => $photos->total(),
'page' => $photos->currentPage(),
'per_page' => $photos->perPage(),
'last_page' => $photos->lastPage(),
],
]);
}
Expand Down
57 changes: 57 additions & 0 deletions database/migrations/2026_10_26_180000_add_search_sort_indexes.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
use MongoDB\Driver\Exception\CommandException;

return new class () extends Migration {
/**
* /v2/incidents/search sorts by (dateTime desc, _id desc). These indexes
* give that order without a blocking in-memory sort (walked backwards):
* the first one for the default fire-only search, the second for all=1.
*/
private function indexes(): array
{
return [
[['isFire' => 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;
}
}
}
};
2 changes: 2 additions & 0 deletions docs/api.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
12 changes: 12 additions & 0 deletions docs/fires/models/Paginator.yaml
Original file line number Diff line number Diff line change
@@ -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
18 changes: 18 additions & 0 deletions docs/fires/models/PhotoPageMeta.yaml
Original file line number Diff line number Diff line change
@@ -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
10 changes: 10 additions & 0 deletions docs/fires/parameters/query/page.yaml
Original file line number Diff line number Diff line change
@@ -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
14 changes: 13 additions & 1 deletion docs/fires/paths/v2-1000ha-burned.yaml
Original file line number Diff line number Diff line change
@@ -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
Expand Down
Loading
Loading