diff --git a/app/Modules/Api/Support/PollingQuery.php b/app/Modules/Api/Support/PollingQuery.php index db9f4e40..109129c0 100644 --- a/app/Modules/Api/Support/PollingQuery.php +++ b/app/Modules/Api/Support/PollingQuery.php @@ -29,6 +29,13 @@ use Illuminate\Support\Carbon; * 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. @@ -39,9 +46,11 @@ class PollingQuery * @template TModel of Model * * @param Builder $query + * @param string $column the timestamp to walk, for a table whose + * rows are appended rather than edited * @return CursorPaginator */ - public function paginate(Request $request, Builder $query, string $table): CursorPaginator + public function paginate(Request $request, Builder $query, string $table, string $column = 'updated_at'): CursorPaginator { $since = $request->query('updated_since'); @@ -53,11 +62,11 @@ class PollingQuery // 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}.updated_at", '>=', Carbon::parse($since)->timezone(config('app.timezone'))) - ->orderBy("{$table}.updated_at") + $query->where("{$table}.{$column}", '>=', Carbon::parse($since)->timezone(config('app.timezone'))) + ->orderBy("{$table}.{$column}") ->orderBy("{$table}.id"); } else { - $query->orderByDesc("{$table}.updated_at") + $query->orderByDesc("{$table}.{$column}") ->orderByDesc("{$table}.id"); } diff --git a/app/Modules/Audit/Http/Controllers/Api/ActivityController.php b/app/Modules/Audit/Http/Controllers/Api/ActivityController.php new file mode 100644 index 00000000..b7e7e61d --- /dev/null +++ b/app/Modules/Audit/Http/Controllers/Api/ActivityController.php @@ -0,0 +1,91 @@ +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__'; + } +} diff --git a/app/Modules/Audit/Http/Resources/Api/ActivityResource.php b/app/Modules/Audit/Http/Resources/Api/ActivityResource.php new file mode 100644 index 00000000..ae46d46b --- /dev/null +++ b/app/Modules/Audit/Http/Resources/Api/ActivityResource.php @@ -0,0 +1,93 @@ + + */ + private const SUBJECTS = [ + \App\Models\User::class => 'user', + \App\Modules\Files\Models\File::class => 'file', + \App\Modules\Files\Models\Folder::class => 'folder', + \App\Modules\Files\Models\Category::class => 'category', + \App\Modules\Groups\Models\Group::class => 'group', + \App\Modules\Identity\Models\Role::class => 'role', + \App\Modules\Clients\Models\ClientCustomField::class => 'client_custom_field', + ]; + + /** + * The map, for the controller's reverse lookup. + * + * @return array + */ + public static function subjects(): array + { + return self::SUBJECTS; + } + + /** + * @return array + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'action' => $this->action->value, + 'created_at' => $this->created_at->toIso8601String(), + + // Snapshots, not joins. The actor may since have been deleted, + // and the entry still has to say who it was. + 'actor' => $this->actor_id === null && $this->actor_name === null ? null : [ + 'id' => $this->actor_id, + 'name' => $this->actor_name, + 'type' => $this->actor_type, + ], + + // How it arrived: a person in the browser, an integration, a + // visitor with no account, or the installation itself. + 'origin' => $this->origin->value, + + 'subject' => $this->subject_type === null ? null : [ + 'type' => self::SUBJECTS[$this->subject_type] ?? 'other', + 'id' => $this->subject_id, + 'name' => $this->subject_name, + ], + + // Whatever the action recorded beyond its subject — who a file + // was shared with, how many files a cascade removed. Shape + // varies by action and is documented per action rather than + // here. + 'context' => $this->context ?? [], + + // ip_address is deliberately absent. It is stored for some + // actions and shown on the activity screen, but handing a + // client's IP to an automation tool is a privacy expansion + // with no matching use — see docs/api-todo.md. + ]; + } +} diff --git a/docs/api-guide.md b/docs/api-guide.md index 2d281e33..8326ec5f 100644 --- a/docs/api-guide.md +++ b/docs/api-guide.md @@ -192,6 +192,59 @@ deletions, that is what webhooks will be for; they are not built yet. --- +## Reacting to things that happen + +Every list above answers "what is there now". `GET /api/v1/activity` answers "what happened", which +is what an automation tool actually needs — and for two of the most useful events it is the only +place to look. + +```bash +curl -H "Authorization: Bearer YOUR_TOKEN" \ + "https://your-install.example.com/api/v1/activity?action[]=file.assigned" +``` + +```json +{ + "data": [ + { + "id": 1284, + "action": "file.assigned", + "created_at": "2026-08-25T09:14:02+00:00", + "actor": { "id": 3, "name": "Dana", "type": "staff" }, + "origin": "ui", + "subject": { "type": "file", "id": 128, "name": "October invoice" }, + "context": { "target": "Acme Ltd" } + } + ] +} +``` + +**Sharing a file leaves no mark on the file.** It writes an assignment, and the file's own +`updated_at` does not move — so polling `/files?updated_since=` will never show you a share, no +matter how often you ask. The same goes for downloads, which are recorded here and nowhere else. + +Repeat `action` for more than one: `?action[]=file.assigned&action[]=file.downloaded`. An action +this installation has never heard of is a `422` rather than being quietly dropped, because ignoring +it would hand back the whole log to a caller who asked for one slice. `subject_type` narrows to one +kind of thing — `file`, `folder`, `user`, `group`, `category`, `role`. + +Polling works as it does everywhere else. Entries are never edited, so `updated_since` walks the +moment each was recorded; the two mean the same thing on a log that is only ever appended to. + +Needs `view_actions_log`, the same permission the activity screen uses, and the same scoping: a +staff member limited to their assigned clients sees their own library and their own actions, never +the whole installation's. + +**No IP addresses.** Some entries record one, and the activity screen shows it. It is left out here +on purpose: handing a client's IP to an automation tool is a privacy question nobody asked to have +answered for them. + +Two things this still cannot tell you. **Deletions** — a deleted row stops appearing, and nothing +marks the moment; that is what webhooks would be for. And anything the log does not record, which +is deliberately less than everything. + +--- + ## Uploading Two ways, and the right one depends on the file. diff --git a/docs/api-zapier.md b/docs/api-zapier.md index 31148c1d..31315a6e 100644 --- a/docs/api-zapier.md +++ b/docs/api-zapier.md @@ -86,10 +86,21 @@ which is exactly what Zapier needs to tell new things from old ones. | What you want to know | URL | |---|---| -| A file was added | `https://your-install.example.com/api/v1/files` | -| A client account was created | `https://your-install.example.com/api/v1/clients` | -| A comment is waiting for approval | `https://your-install.example.com/api/v1/comments/pending` | -| A group was created | `https://your-install.example.com/api/v1/groups` | +| **A file was shared with a client** | `…/api/v1/activity?action[]=file.assigned` | +| **A client downloaded a file** | `…/api/v1/activity?action[]=file.downloaded` | +| **Somebody left a comment** | `…/api/v1/activity?action[]=comment.posted` | +| Anything at all happened | `…/api/v1/activity` | +| A file was added | `…/api/v1/files` | +| A client account was created | `…/api/v1/clients` | +| A comment is waiting for approval | `…/api/v1/comments/pending` | +| A group was created | `…/api/v1/groups` | + +The first three are the ones people usually want, and they only work through +`/api/v1/activity`. Sharing a file writes an assignment and leaves the file itself untouched, so +polling the file list will never show you a share; downloads are recorded in the activity log and +nowhere else. Repeat `action[]` to watch for more than one kind of thing at once. + +Watching activity needs a token with **view activity log** ticked. In the Zapier step, set: @@ -100,6 +111,28 @@ In the Zapier step, set: ### What comes back +From `/api/v1/activity`: + +```json +{ + "data": [ + { + "id": 1284, + "action": "file.assigned", + "created_at": "2026-08-25T09:14:02+00:00", + "actor": { "id": 3, "name": "Dana", "type": "staff" }, + "subject": { "type": "file", "id": 128, "name": "October invoice" }, + "context": { "target": "Acme Ltd" } + } + ] +} +``` + +So a Slack message can read *"Dana shared October invoice with Acme Ltd"* using `actor.name`, +`subject.name` and `context.target`. + +From `/api/v1/files`: + ```json { "data": [ @@ -208,14 +241,18 @@ Only clients can be group members. Passing a staff account is refused. ## Three complete examples -### 1. Tell the team in Slack when a file arrives +### 1. Tell the team in Slack when a client downloads something -- **Trigger**: Webhooks by Zapier → Retrieve Poll → `GET /api/v1/files` +- **Trigger**: Webhooks by Zapier → Retrieve Poll → + `GET /api/v1/activity?action[]=file.downloaded` - **Action**: Slack → Send Channel Message -In the Slack message, use the `name` and `size` fields from the trigger. Something like: +Use the fields from the trigger: -> New file in ProjectSend: **{{name}}** ({{size}} bytes) +> **{{actor__name}}** downloaded **{{subject__name}}** + +This is the one people ask for most, and it is only possible through the activity feed — a download +leaves no trace on the file itself. ### 2. Turn a form submission into a client account diff --git a/docs/api/openapi.json b/docs/api/openapi.json index fea54fb3..dd322939 100644 --- a/docs/api/openapi.json +++ b/docs/api/openapi.json @@ -16,6 +16,175 @@ } ], "paths": { + "/activity": { + "get": { + "operationId": "activity.index", + "description": "Filter by `action` \u2014 repeat the parameter for more than one, as\n`?action[]=file.assigned&action[]=file.downloaded`. `subject_type`\nnarrows to one kind of thing (`file`, `user`, `group`, \u2026).\n\nEntries are never edited, so `updated_since` walks the moment each\none was recorded. Everything else about polling is the shape every\nlist endpoint here shares.\n\nScoped to what the caller may read: a staff member limited to their\nassigned clients sees entries about their own library and their own\nactions, never the whole installation's.\n\nRequires a token with the ability: `view_actions_log`.", + "summary": "List activity, newest first", + "tags": [ + "Activity" + ], + "parameters": [ + { + "name": "updated_since", + "in": "query", + "schema": { + "type": [ + "string", + "null" + ], + "format": "date-time" + } + }, + { + "name": "per_page", + "in": "query", + "schema": { + "type": [ + "integer", + "null" + ], + "minimum": 1, + "maximum": 100 + } + }, + { + "name": "cursor", + "in": "query", + "schema": { + "type": [ + "string", + "null" + ] + } + }, + { + "name": "action[]", + "in": "query", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Action" + } + } + }, + { + "name": "subject_type", + "in": "query", + "schema": { + "type": [ + "string", + "null" + ], + "maxLength": 64 + } + } + ], + "responses": { + "200": { + "description": "created_at, not updated_at: the log is appended to and never\nedited, and has no updated_at column to walk.\n\n\n\nPaginated set of `ActivityResource`", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ActivityResource" + } + }, + "links": { + "type": "object", + "properties": { + "first": { + "type": [ + "string", + "null" + ] + }, + "last": { + "type": [ + "string", + "null" + ] + }, + "prev": { + "type": [ + "string", + "null" + ] + }, + "next": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "first", + "last", + "prev", + "next" + ] + }, + "meta": { + "type": "object", + "properties": { + "path": { + "type": [ + "string", + "null" + ], + "description": "Base path for paginator generated URLs." + }, + "per_page": { + "type": "integer", + "description": "Number of items shown per page.", + "minimum": 0 + }, + "next_cursor": { + "type": [ + "string", + "null" + ], + "description": "The \"cursor\" that points to the next set of items." + }, + "prev_cursor": { + "type": [ + "string", + "null" + ], + "description": "The \"cursor\" that points to the previous set of items." + } + }, + "required": [ + "path", + "per_page", + "next_cursor", + "prev_cursor" + ] + } + }, + "required": [ + "data", + "links", + "meta" + ] + } + } + } + }, + "422": { + "$ref": "#/components/responses/ValidationException" + }, + "401": { + "$ref": "#/components/responses/AuthenticationException" + } + } + } + }, "/uploads": { "post": { "operationId": "uploads.store", @@ -3308,6 +3477,206 @@ } }, "schemas": { + "Action": { + "type": "string", + "description": "Every action the activity log can record \u2014 v2's replacement for v1's ~45 numbered action types in ActionsLog. Modules add their own cases (file.uploaded, group.created, \u2026) as they land; the string values are stable identifiers stored in the database.\n", + "enum": [ + "setup.completed", + "settings.updated", + "application.updated", + "auth.login", + "auth.logout", + "user.created", + "user.updated", + "user.deleted", + "user.activated", + "user.deactivated", + "account.erased", + "account_content.cascade_deleted", + "account_content.reassigned", + "account.converted_to_client", + "account.converted_to_staff", + "client.self_registered", + "ldap.client_provisioned", + "social.client_provisioned", + "social.account_linked", + "social.account_unlinked", + "client.approved", + "client.denied", + "file.uploaded", + "file.updated", + "file.deleted", + "file.downloaded", + "file.previewed", + "file.assigned", + "file.unassigned", + "file.version_linked", + "file.version_unlinked", + "share_link.created", + "share_link.revoked", + "share_link.downloaded", + "public_file.downloaded", + "public_file.previewed", + "folder.created", + "folder.renamed", + "folder.moved", + "folder.deleted", + "folder.shared", + "folder.unshared", + "file.made_public", + "file.made_private", + "folder.made_public", + "folder.made_private", + "upload.aborted", + "file.imported", + "orphan_file.deleted", + "orphan_file.auto_deleted", + "file.expired_deleted", + "group.membership_left", + "group.membership_requested", + "group.membership_approved", + "group.membership_denied", + "group.created", + "group.updated", + "group.deleted", + "group.made_public", + "group.made_private", + "group.member_added", + "group.member_removed", + "role.created", + "role.updated", + "role.deleted", + "category.created", + "category.renamed", + "category.deleted", + "client_custom_field.created", + "client_custom_field.updated", + "client_custom_field.deleted", + "profile.updated", + "password.updated", + "two_factor.enabled", + "two_factor.disabled", + "two_factor.recovery_codes_regenerated", + "two_factor.reset", + "api_token.created", + "api_token.updated", + "api_token.revoked", + "custom_asset.created", + "custom_asset.updated", + "custom_asset.deleted", + "custom_asset.enabled", + "custom_asset.disabled", + "comment.posted", + "comment.posted_by_visitor", + "comment.edited", + "comment.deleted", + "comment.approved" + ], + "title": "Action" + }, + "ActivityResource": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "action": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "actor": { + "type": [ + "object", + "null" + ], + "description": "Snapshots, not joins. The actor may since have been deleted,\nand the entry still has to say who it was.", + "properties": { + "id": { + "type": [ + "integer", + "null" + ] + }, + "name": { + "type": [ + "string", + "null" + ] + }, + "type": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "id", + "name", + "type" + ] + }, + "origin": { + "type": "string", + "description": "How it arrived: a person in the browser, an integration, a\nvisitor with no account, or the installation itself." + }, + "subject": { + "type": [ + "object", + "null" + ], + "properties": { + "type": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "string", + "enum": [ + "other" + ] + } + ] + }, + "id": { + "type": [ + "integer", + "null" + ] + }, + "name": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "type", + "id", + "name" + ] + }, + "context": { + "type": "array", + "description": "Whatever the action recorded beyond its subject \u2014 who a file\nwas shared with, how many files a cascade removed. Shape\nvaries by action and is documented per action rather than\nhere.", + "items": {} + } + }, + "required": [ + "id", + "action", + "created_at", + "actor", + "origin", + "subject", + "context" + ], + "title": "ActivityResource" + }, "ClientResource": { "type": "object", "properties": { diff --git a/routes/api.php b/routes/api.php index 50ff4dc1..70d7f9e3 100644 --- a/routes/api.php +++ b/routes/api.php @@ -6,6 +6,7 @@ use App\Modules\Api\Events\ApiModule; use App\Modules\Api\Events\RegisteringApiModules; use App\Modules\Api\Http\Controllers\CurrentTokenController; use App\Modules\Api\Http\Controllers\MeController; +use App\Modules\Audit\Http\Controllers\Api\ActivityController; use App\Modules\Api\Http\Controllers\OpenApiController; use App\Modules\Clients\Http\Controllers\Api\ClientsController; use App\Modules\Comments\Http\Controllers\Api\CommentModerationController; @@ -274,6 +275,27 @@ Route::middleware(['auth:sanctum', 'api-active', 'staff-token'])->group(function ->name('api.groups.members.destroy'); }); + /* + |---------------------------------------------------------------------- + | Activity + |---------------------------------------------------------------------- + | + | The one endpoint that answers "what happened", rather than "what is + | there now". An integration reacting to events has nothing else to + | poll: sharing a file writes an assignment row and leaves the file + | untouched, and a download is only ever recorded here, so neither is + | visible from any other list. + | + | `view_actions_log` is the same permission the activity screen uses, + | and ActivityLogScope narrows the rows the same way it does there — + | a staff member limited to their assigned clients must not read the + | whole installation's log through a token when the screen would not + | show it to them. + | + */ + Route::get('activity', [ActivityController::class, 'index']) + ->middleware('token-can:view_actions_log')->name('api.activity.index'); + /* |---------------------------------------------------------------------- | Module endpoints diff --git a/tests/Feature/Api/ActivityTest.php b/tests/Feature/Api/ActivityTest.php new file mode 100644 index 00000000..4cf19769 --- /dev/null +++ b/tests/Feature/Api/ActivityTest.php @@ -0,0 +1,159 @@ +staff = User::factory()->create(); +}); + +function activityToken(User $user, array $abilities = [Permission::ViewActionsLog->value]): string +{ + return $user->createToken('Zapier', $abilities)->plainTextToken; +} + +test('the feed lists what happened, newest first', function () { + $logger = app(ActivityLogger::class); + $logger->log(Action::FileUploaded, $this->staff, context: ['name' => 'older']); + $logger->log(Action::FileDownloaded, $this->staff, context: ['name' => 'newer']); + + $response = $this->withToken(activityToken($this->staff)) + ->getJson('/api/v1/activity') + ->assertOk(); + + // Newest first is what a polling trigger reads: it takes the first + // page and de-duplicates by id. + expect($response->json('data.0.action'))->toBe('file.downloaded') + ->and($response->json('data.1.action'))->toBe('file.uploaded'); +}); + +test('sharing a file is visible here and nowhere else', function () { + // The reason this endpoint exists. FileSharing::assign() writes an + // assignment row and never touches the file, so a caller polling + // /files?updated_since= sees nothing at all. + $client = User::factory()->client()->create(); + $file = File::factory()->create(['uploaded_by' => $this->staff->id]); + + $token = activityToken($this->staff, [ + Permission::ViewActionsLog->value, + Permission::EditFiles->value, + Permission::Upload->value, + ]); + + $untouched = $file->fresh()->updated_at; + + $this->withToken($token) + ->postJson("/api/v1/files/{$file->id}/assignments", ['type' => 'client', 'id' => $client->id]) + ->assertSuccessful(); + + // The file itself is exactly as it was, so no amount of polling + // /files?updated_since= will ever surface the share. + expect($file->fresh()->updated_at->equalTo($untouched))->toBeTrue(); + + $activity = $this->withToken($token) + ->getJson('/api/v1/activity?action[]='.Action::FileAssigned->value) + ->assertOk(); + + expect($activity->json('data.0.action'))->toBe('file.assigned') + ->and($activity->json('data.0.subject.type'))->toBe('file') + ->and($activity->json('data.0.subject.id'))->toBe($file->id); +}); + +test('entries can be narrowed to the actions a caller cares about', function () { + $logger = app(ActivityLogger::class); + $logger->log(Action::FileUploaded, $this->staff); + $logger->log(Action::FileDownloaded, $this->staff); + $logger->log(Action::UserCreated, $this->staff); + + $response = $this->withToken(activityToken($this->staff)) + ->getJson('/api/v1/activity?action[]=file.downloaded&action[]=user.created') + ->assertOk(); + + expect(collect($response->json('data'))->pluck('action')->sort()->values()->all()) + ->toBe(['file.downloaded', 'user.created']); +}); + +test('an action nobody has heard of is refused, not ignored', function () { + // Silently ignoring it would hand back the whole log to a caller who + // asked for one slice of it. + $this->withToken(activityToken($this->staff)) + ->getJson('/api/v1/activity?action[]=file.teleported') + ->assertStatus(422); +}); + +test('an unknown subject type matches nothing rather than everything', function () { + app(ActivityLogger::class)->log(Action::FileUploaded, $this->staff); + + $this->withToken(activityToken($this->staff)) + ->getJson('/api/v1/activity?subject_type=spaceship') + ->assertOk() + ->assertJsonPath('data', []); +}); + +test('subjects are named, never classed', function () { + $group = Group::query()->create(['name' => 'Acme', 'public' => false]); + app(ActivityLogger::class)->log(Action::GroupCreated, $this->staff, $group); + + $response = $this->withToken(activityToken($this->staff)) + ->getJson('/api/v1/activity') + ->assertOk(); + + // A class name on the wire would make moving a model between + // namespaces a breaking API change. + expect($response->json('data.0.subject.type'))->toBe('group') + ->and(json_encode($response->json()))->not->toContain('App\\Modules'); +}); + +test('polling walks forward without skipping or repeating', function () { + $logger = app(ActivityLogger::class); + $logger->log(Action::FileUploaded, $this->staff); + + $token = activityToken($this->staff); + $first = $this->withToken($token)->getJson('/api/v1/activity')->assertOk(); + $watermark = $first->json('data.0.created_at'); + + $logger->log(Action::FileDownloaded, $this->staff); + + $next = $this->withToken($token) + ->getJson('/api/v1/activity?updated_since='.urlencode($watermark)) + ->assertOk(); + + // The boundary row is inclusive on purpose and de-duplicated by id, + // so the new entry must be there and must come last in the walk. + expect(collect($next->json('data'))->pluck('action'))->toContain('file.downloaded'); +}); + +test('a token without the permission cannot read the log', function () { + $this->withToken(activityToken($this->staff, [Permission::Upload->value])) + ->getJson('/api/v1/activity') + ->assertForbidden(); +}); + +test('an IP address is never handed to an integration', function () { + app(ActivityLogger::class)->log(Action::FileDownloaded, $this->staff); + + $response = $this->withToken(activityToken($this->staff)) + ->getJson('/api/v1/activity') + ->assertOk(); + + expect($response->json('data.0'))->not->toHaveKey('ip_address'); +});