Files
ignacionelson 7646e99f33 Add an activity endpoint, so an integration can react rather than poll for shape
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.
2026-08-25 18:44:51 -03:00

92 lines
3.4 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Modules\Audit\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Modules\Api\Support\PollingQuery;
use App\Modules\Audit\Action;
use App\Modules\Audit\ActivityLog;
use App\Modules\Audit\ActivityLogScope;
use App\Modules\Audit\Http\Resources\Api\ActivityResource;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Illuminate\Validation\Rule;
/**
* What has happened in this installation.
*
* The endpoint automation tools actually need. Every other list here
* answers "what is there now"; a caller that wants to *react* — post to
* Slack when a file is shared, add a row when a client downloads one —
* needs to know that something happened, and the shape of the thing
* afterwards does not say. Sharing a file writes an assignment row and
* never touches the file, so polling the file list cannot see it at all.
*
* One feed rather than one endpoint per event, because the log already
* records every one of them and a caller filtering by `action` gets any
* event the application ever grows without waiting for an endpoint.
*/
class ActivityController extends Controller
{
public function __construct(
private readonly PollingQuery $polling,
private readonly ActivityLogScope $scope,
) {}
/**
* List activity, newest first.
*
* Filter by `action` — repeat the parameter for more than one, as
* `?action[]=file.assigned&action[]=file.downloaded`. `subject_type`
* narrows to one kind of thing (`file`, `user`, `group`, …).
*
* Entries are never edited, so `updated_since` walks the moment each
* one was recorded. Everything else about polling is the shape every
* list endpoint here shares.
*
* Scoped to what the caller may read: a staff member limited to their
* assigned clients sees entries about their own library and their own
* actions, never the whole installation's.
*/
public function index(Request $request): AnonymousResourceCollection
{
$filters = $request->validate($this->polling->rules() + [
'action' => ['nullable', 'array'],
'action.*' => [Rule::enum(Action::class)],
'subject_type' => ['nullable', 'string', 'max:64'],
]);
$viewer = $request->user();
assert($viewer !== null);
$query = $this->scope->apply(ActivityLog::query(), $viewer);
if (($filters['action'] ?? []) !== []) {
$query->whereIn('action', $filters['action']);
}
if (($filters['subject_type'] ?? null) !== null) {
$query->where('subject_type', $this->subjectClass($filters['subject_type']));
}
// created_at, not updated_at: the log is appended to and never
// edited, and has no updated_at column to walk.
return ActivityResource::collection(
$this->polling->paginate($request, $query, 'activity_log', 'created_at')
);
}
/**
* The public name for a kind of subject, back to the class the column
* actually holds. An unknown name matches nothing rather than
* everything — a filter that silently ignores what it was given would
* hand back the whole log to a caller who asked for one slice of it.
*/
private function subjectClass(string $type): string
{
return array_search($type, ActivityResource::subjects(), true) ?: '__no_such_subject__';
}
}