mirror of
https://github.com/projectsend/projectsend.git
synced 2026-09-16 16:45:07 +00:00
7646e99f33
Every list in /api/v1 answers "what is there now". Nothing answered "what happened", and for the two events people most want to act on there was nowhere to look at all. Sharing a file writes an assignment row and never touches the file, so no amount of polling /files?updated_since= will ever show a share. A download is recorded only in the activity log. So the most requested automations for a file-sharing product — tell me when a client gets a file, tell me when they open it — were not possible to build. GET /api/v1/activity is one feed rather than one endpoint per event, because the log already records every one of them and a caller filtering by action gets whatever the application grows later without waiting for us to expose it. It reuses what already exists: view_actions_log is the permission the activity screen uses, and ActivityLogScope narrows the rows the same way, so a staff member limited to their assigned clients cannot read the whole installation's log through a token when the screen would not show it. Two deliberate limits. Class names never reach the wire — subject.type is a stable public string, or moving a model between namespaces would be a breaking change to a frozen contract. And no ip_address, though the column exists and the screen shows it: a person looking at a log has decided to look, where an integration streams every row to somebody else's servers by default. PollingQuery grew an optional column so it can walk a table that is appended to rather than edited. The parameter stays updated_since everywhere, because on an append-only log the two timestamps are the same thing and one shape learned once is worth more than a second name.
105 lines
4.3 KiB
PHP
105 lines
4.3 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Modules\Api\Support;
|
|
|
|
use Illuminate\Database\Eloquent\Builder;
|
|
use Illuminate\Database\Eloquent\Model;
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Pagination\CursorPaginator;
|
|
use Illuminate\Support\Carbon;
|
|
|
|
/**
|
|
* The shape every list endpoint shares, so an integration learns it once.
|
|
*
|
|
* Two modes, chosen by whether the caller passed `updated_since`:
|
|
*
|
|
* - **Polling.** Ordered by (updated_at, id) *ascending* and filtered to
|
|
* rows touched at or after the given time. That ordering is what makes
|
|
* a repeated poll safe: new and edited rows always land at the end of
|
|
* the walk, so paging forward with a cursor visits every row exactly
|
|
* once. Newest-first would insert new rows at the front and silently
|
|
* shift everything the caller had not read yet.
|
|
* - **Browsing.** Newest-first, for a human looking at a list.
|
|
*
|
|
* `updated_since` is inclusive on purpose. A caller is told to poll with
|
|
* the highest `updated_at` it has seen, and two rows can share a
|
|
* timestamp to the second; excluding the boundary would drop the second
|
|
* one forever. The cost is re-seeing the boundary row, which a client
|
|
* de-duplicates by id — the safe direction of the trade.
|
|
*
|
|
* Some tables have no `updated_at` because their rows are never edited —
|
|
* the activity log is one. They pass their own column instead. The
|
|
* *parameter* stays `updated_since` for every endpoint even so: the
|
|
* shape being learned once is worth more than a second name that would
|
|
* behave identically, since on an append-only table the two timestamps
|
|
* are the same thing.
|
|
*
|
|
* Known limitation, documented rather than papered over: polling cannot
|
|
* observe deletions. A soft-deleted row simply stops appearing. Webhooks
|
|
* are the fix, and are deliberately a later phase.
|
|
*/
|
|
class PollingQuery
|
|
{
|
|
/**
|
|
* @template TModel of Model
|
|
*
|
|
* @param Builder<TModel> $query
|
|
* @param string $column the timestamp to walk, for a table whose
|
|
* rows are appended rather than edited
|
|
* @return CursorPaginator<int, TModel>
|
|
*/
|
|
public function paginate(Request $request, Builder $query, string $table, string $column = 'updated_at'): CursorPaginator
|
|
{
|
|
$since = $request->query('updated_since');
|
|
|
|
if (is_string($since) && $since !== '') {
|
|
// Parsed, never passed through as a string. Callers send proper
|
|
// ISO 8601 ("2026-08-06T05:00:00+02:00"), and the database will
|
|
// not compare that against a datetime column — MySQL fails to
|
|
// cast the `T` and the offset and silently matches nothing, so
|
|
// a polling client would see an empty result forever instead of
|
|
// an error. Carbon also normalises the offset into the app's
|
|
// timezone, so a caller in any timezone gets the same rows.
|
|
$query->where("{$table}.{$column}", '>=', Carbon::parse($since)->timezone(config('app.timezone')))
|
|
->orderBy("{$table}.{$column}")
|
|
->orderBy("{$table}.id");
|
|
} else {
|
|
$query->orderByDesc("{$table}.{$column}")
|
|
->orderByDesc("{$table}.id");
|
|
}
|
|
|
|
return $query->cursorPaginate($this->perPage($request))->withQueryString();
|
|
}
|
|
|
|
/**
|
|
* The cap exists so one caller cannot turn a list endpoint into a
|
|
* full-table export in a single request.
|
|
*/
|
|
public function perPage(Request $request): int
|
|
{
|
|
$requested = (int) $request->query('per_page', (string) config('api.pagination.per_page'));
|
|
$max = (int) config('api.pagination.max_per_page');
|
|
|
|
return max(1, min($requested, $max));
|
|
}
|
|
|
|
/**
|
|
* Validation rules a controller merges into its own, so `updated_since`
|
|
* is rejected consistently rather than silently ignored when malformed
|
|
* — a caller polling with a bad timestamp would otherwise re-read the
|
|
* whole table on every tick and never notice.
|
|
*
|
|
* @return array<string, list<string>>
|
|
*/
|
|
public function rules(): array
|
|
{
|
|
return [
|
|
'updated_since' => ['nullable', 'date'],
|
|
'per_page' => ['nullable', 'integer', 'min:1', 'max:'.(int) config('api.pagination.max_per_page')],
|
|
'cursor' => ['nullable', 'string'],
|
|
];
|
|
}
|
|
}
|