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

@@ -0,0 +1,63 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\V1\Admin;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Spatie\Activitylog\Models\Activity;
final class AdminActivityLogController extends Controller
{
public function index(): JsonResponse
{
$query = Activity::query()->with('causer')->latest();
if ($causerId = request('causer_id')) {
$query->where('causer_id', $causerId);
}
if ($subjectType = request('subject_type')) {
$query->where('subject_type', $subjectType);
}
if ($logName = request('log_name')) {
$query->where('log_name', $logName);
}
if ($from = request('from')) {
$query->where('created_at', '>=', $from);
}
if ($to = request('to')) {
$query->where('created_at', '<=', $to);
}
$activities = $query->paginate(25);
return response()->json([
'data' => $activities->map(fn (Activity $activity) => [
'id' => $activity->id,
'log_name' => $activity->log_name,
'description' => $activity->description,
'event' => $activity->event,
'causer' => $activity->causer ? [
'id' => $activity->causer->id,
'name' => $activity->causer->full_name ?? $activity->causer->name ?? null,
'email' => $activity->causer->email ?? null,
] : null,
'subject_type' => $activity->subject_type,
'subject_id' => $activity->subject_id,
'properties' => $activity->properties,
'created_at' => $activity->created_at->toIso8601String(),
]),
'meta' => [
'current_page' => $activities->currentPage(),
'last_page' => $activities->lastPage(),
'per_page' => $activities->perPage(),
'total' => $activities->total(),
],
]);
}
}

View File

@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\V1\Admin;
use App\Http\Controllers\Controller;
use App\Http\Resources\Admin\AdminUserResource;
use App\Models\User;
use App\Services\ImpersonationService;
use Illuminate\Http\JsonResponse;
final class AdminImpersonationController extends Controller
{
public function __construct(
private readonly ImpersonationService $impersonationService,
) {}
public function start(User $user): JsonResponse
{
/** @var User $admin */
$admin = auth()->user();
$result = $this->impersonationService->start($admin, $user);
return $this->success([
'token' => $result['token'],
'user' => new AdminUserResource($result['user']->load('organisations')),
'admin_id' => $result['admin_id'],
]);
}
public function stop(): JsonResponse
{
/** @var User $currentUser */
$currentUser = auth()->user();
$admin = $this->impersonationService->stop($currentUser);
return $this->success([
'user' => new AdminUserResource($admin->load('organisations')),
]);
}
}

View File

@@ -0,0 +1,94 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\V1\Admin;
use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\AdminUpdateOrganisationRequest;
use App\Http\Resources\Admin\AdminOrganisationResource;
use App\Models\Organisation;
use App\Models\Person;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
final class AdminOrganisationController extends Controller
{
public function index(): AnonymousResourceCollection
{
$query = Organisation::withoutGlobalScopes()
->withCount(['events', 'users']);
if ($search = request('search')) {
$query->where(function ($q) use ($search) {
$q->where('name', 'like', "%{$search}%")
->orWhere('slug', 'like', "%{$search}%");
});
}
if ($billingStatus = request('billing_status')) {
$query->where('billing_status', $billingStatus);
}
$sortBy = request('sort', 'name');
$sortDirection = request('direction', 'asc');
$query->orderBy(
in_array($sortBy, ['name', 'created_at']) ? $sortBy : 'name',
$sortDirection === 'desc' ? 'desc' : 'asc',
);
return AdminOrganisationResource::collection($query->paginate());
}
public function show(string $organisationId): JsonResponse
{
$organisation = Organisation::withoutGlobalScopes()
->withCount(['events', 'users'])
->findOrFail($organisationId);
$organisation->total_persons = Person::withoutGlobalScopes()
->whereIn('event_id', $organisation->events()->select('id'))
->count();
return $this->success(new AdminOrganisationResource($organisation));
}
public function store(): void
{
// Organisations are created via the regular endpoint
abort(405);
}
public function update(AdminUpdateOrganisationRequest $request, string $organisationId): JsonResponse
{
$organisation = Organisation::withoutGlobalScopes()->findOrFail($organisationId);
$organisation->update($request->validated());
activity('admin')
->causedBy(auth()->user())
->performedOn($organisation)
->event('admin.organisation.updated')
->withProperties($request->validated())
->log('Updated organisation ' . $organisation->name);
$organisation->loadCount(['events', 'users']);
return $this->success(new AdminOrganisationResource($organisation));
}
public function destroy(string $organisationId): JsonResponse
{
$organisation = Organisation::withoutGlobalScopes()->findOrFail($organisationId);
activity('admin')
->causedBy(auth()->user())
->performedOn($organisation)
->event('admin.organisation.deleted')
->log('Deleted organisation ' . $organisation->name);
$organisation->delete();
return response()->json(null, 204);
}
}

View File

@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\V1\Admin;
use App\Http\Controllers\Controller;
use App\Models\Event;
use App\Models\Organisation;
use App\Models\Person;
use App\Models\User;
use Illuminate\Http\JsonResponse;
final class AdminStatsController extends Controller
{
public function index(): JsonResponse
{
return response()->json([
'data' => [
'organisations' => [
'total' => Organisation::withoutGlobalScopes()->count(),
'by_billing_status' => Organisation::withoutGlobalScopes()
->selectRaw('billing_status, COUNT(*) as count')
->groupBy('billing_status')
->pluck('count', 'billing_status'),
],
'events' => [
'total' => Event::withoutGlobalScopes()->count(),
'by_status' => Event::withoutGlobalScopes()
->selectRaw('status, COUNT(*) as count')
->groupBy('status')
->pluck('count', 'status'),
],
'users' => [
'total' => User::count(),
'verified' => User::whereNotNull('email_verified_at')->count(),
],
'persons' => [
'total' => Person::withoutGlobalScopes()->count(),
],
],
]);
}
}

View File

@@ -0,0 +1,90 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\V1\Admin;
use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\AdminUpdateUserRequest;
use App\Http\Resources\Admin\AdminUserResource;
use App\Models\User;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
final class AdminUserController extends Controller
{
public function index(): AnonymousResourceCollection
{
$query = User::with('organisations');
if ($search = request('search')) {
$query->where(function ($q) use ($search) {
$q->where('first_name', 'like', "%{$search}%")
->orWhere('last_name', 'like', "%{$search}%")
->orWhere('email', 'like', "%{$search}%");
});
}
if ($organisationId = request('organisation_id')) {
$query->whereHas('organisations', fn ($q) => $q->where('organisations.id', $organisationId));
}
if ($role = request('role')) {
$query->role($role);
}
$query->orderBy('first_name')->orderBy('last_name');
return AdminUserResource::collection($query->paginate());
}
public function show(User $user): JsonResponse
{
$user->load('organisations');
return $this->success(new AdminUserResource($user));
}
public function update(AdminUpdateUserRequest $request, User $user): JsonResponse
{
$validated = $request->validated();
$roles = $validated['roles'] ?? null;
unset($validated['roles']);
if (! empty($validated)) {
$user->update($validated);
}
if ($roles !== null) {
// Sync only platform-level roles, preserving org/event roles
$platformRoles = ['super_admin', 'support_agent'];
$currentRoles = $user->getRoleNames()->filter(fn ($r) => ! in_array($r, $platformRoles))->all();
$user->syncRoles(array_merge($currentRoles, $roles));
}
activity('admin')
->causedBy(auth()->user())
->performedOn($user)
->event('admin.user.updated')
->withProperties($request->validated())
->log('Updated user ' . $user->full_name);
$user->load('organisations');
return $this->success(new AdminUserResource($user));
}
public function destroy(User $user): JsonResponse
{
activity('admin')
->causedBy(auth()->user())
->performedOn($user)
->event('admin.user.deleted')
->log('Deleted user ' . $user->full_name);
$user->tokens()->delete();
$user->delete();
return response()->json(null, 204);
}
}