feat: platform admin backend — controllers, services, routes, tests

Add cross-organisation admin API endpoints behind role:super_admin middleware:
- AdminOrganisationController: CRUD with search, filter, billing_status management
- AdminUserController: user management with role assignment across orgs
- AdminStatsController: platform-wide aggregate statistics
- AdminActivityLogController: filterable activity log viewer
- AdminImpersonationController + ImpersonationService: user impersonation with
  token-based session management and activity logging
- BillingStatus enum, form requests, API resources, 23 feature tests

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-14 23:33:16 +02:00
parent ec31646a93
commit ddf26dad33
18 changed files with 1299 additions and 0 deletions

View File

@@ -726,3 +726,199 @@ Additional filter parameters on `GET /organisations/{org}/events/{event}/persons
- `?has_preference=true` — only persons who submitted section preferences
_(Extend this contract per module as endpoints are implemented.)_
## Platform Admin
All admin endpoints require `auth:sanctum` + `role:super_admin`. They bypass OrganisationScope and query across all organisations.
Base path: `/api/v1/admin/`
### Admin Organisations
- `GET /admin/organisations` — list all organisations (paginated)
- `GET /admin/organisations/{organisation}` — show with counts and total persons
- `POST /admin/organisations` — not supported (405), use regular endpoint
- `PUT /admin/organisations/{organisation}` — update name, slug, billing_status, settings
- `DELETE /admin/organisations/{organisation}` — soft delete
#### Query Parameters (index)
- `search` — filter by name or slug (partial match)
- `billing_status` — filter by billing status (`trial`, `active`, `suspended`, `cancelled`)
- `sort` — sort field: `name` (default), `created_at`
- `direction` — sort direction: `asc` (default), `desc`
#### AdminOrganisationResource
```json
{
"id": "01JXYZ...",
"name": "Festival Corp",
"slug": "festival-corp",
"billing_status": "active",
"billing_status_label": "Active",
"settings": {},
"events_count": 5,
"users_count": 12,
"total_persons": 342,
"created_at": "2026-01-15T10:00:00+00:00",
"updated_at": "2026-04-10T12:00:00+00:00",
"deleted_at": null
}
```
#### Update Body
```json
{
"name": "Festival Corp",
"slug": "festival-corp",
"billing_status": "suspended",
"settings": {}
}
```
### Admin Users
- `GET /admin/users` — list all users with organisation memberships (paginated)
- `GET /admin/users/{user}` — show with organisations and roles
- `PUT /admin/users/{user}` — update name, email, timezone, locale, platform roles
- `DELETE /admin/users/{user}` — soft delete + revoke all tokens
#### Query Parameters (index)
- `search` — filter by first_name, last_name, or email (partial match)
- `organisation_id` — filter by organisation membership
- `role` — filter by Spatie role name
#### AdminUserResource
```json
{
"id": "01JXYZ...",
"first_name": "Jan",
"last_name": "de Vries",
"full_name": "Jan de Vries",
"email": "jan@example.nl",
"avatar": null,
"timezone": "Europe/Amsterdam",
"locale": "nl",
"email_verified_at": "2026-01-15T10:00:00+00:00",
"created_at": "2026-01-15T10:00:00+00:00",
"roles": ["super_admin"],
"is_super_admin": true,
"organisations": [
{ "id": "01JXYZ...", "name": "Festival Corp", "slug": "festival-corp", "role": "org_admin" }
]
}
```
#### Update Body
```json
{
"first_name": "Jan",
"last_name": "de Vries",
"email": "jan@example.nl",
"timezone": "Europe/Amsterdam",
"locale": "nl",
"roles": ["super_admin"]
}
```
`roles` accepts platform-level roles only: `super_admin`, `support_agent`. Organisation/event roles are managed via the regular endpoints.
### Admin Stats
- `GET /admin/stats` — platform-wide aggregate counts
#### Response
```json
{
"data": {
"organisations": {
"total": 15,
"by_billing_status": { "trial": 3, "active": 10, "suspended": 1, "cancelled": 1 }
},
"events": {
"total": 42,
"by_status": { "draft": 10, "published": 8, "registration_open": 12, "showday": 5, "closed": 7 }
},
"users": {
"total": 156,
"verified": 142
},
"persons": {
"total": 2340
}
}
}
```
### Admin Activity Log
- `GET /admin/activity-log` — paginated activity log (25 per page)
#### Query Parameters
- `causer_id` — filter by user who caused the action
- `subject_type` — filter by subject model type
- `log_name` — filter by log name (e.g. `admin`, `default`)
- `from` — filter from date (ISO 8601)
- `to` — filter to date (ISO 8601)
#### Response
```json
{
"data": [
{
"id": 1,
"log_name": "admin",
"description": "Updated organisation Festival Corp",
"event": "admin.organisation.updated",
"causer": { "id": "01JXYZ...", "name": "Super Admin", "email": "admin@crewli.app" },
"subject_type": "App\\Models\\Organisation",
"subject_id": "01JXYZ...",
"properties": { "billing_status": "suspended" },
"created_at": "2026-04-14T10:00:00+00:00"
}
],
"meta": { "current_page": 1, "last_page": 1, "per_page": 25, "total": 1 }
}
```
### Admin Impersonation
- `POST /admin/impersonate/{user}` — start impersonating a user (requires `role:super_admin`)
- `POST /admin/stop-impersonation` — stop impersonation (requires `auth:sanctum` only, callable by impersonated user)
#### Start Response
```json
{
"data": {
"token": "1|abc123...",
"user": { "...AdminUserResource..." },
"admin_id": "01JXYZ..."
}
}
```
#### Stop Response
```json
{
"data": {
"user": { "...AdminUserResource (original admin)..." }
}
}
```
#### Business Rules
- Cannot impersonate another super_admin (403)
- Impersonation token has name `impersonation-by-{admin_id}`
- Admin ID is cached for 4 hours at key `impersonation:{token_id}`
- Activity log records both start (`admin.impersonation.started`) and stop (`admin.impersonation.stopped`)