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,23 @@
<?php
declare(strict_types=1);
namespace App\Enums;
enum BillingStatus: string
{
case TRIAL = 'trial';
case ACTIVE = 'active';
case SUSPENDED = 'suspended';
case CANCELLED = 'cancelled';
public function label(): string
{
return match ($this) {
self::TRIAL => 'Trial',
self::ACTIVE => 'Active',
self::SUSPENDED => 'Suspended',
self::CANCELLED => 'Cancelled',
};
}
}

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);
}
}

View File

@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\Admin;
use App\Enums\BillingStatus;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Rules\Enum;
final class AdminUpdateOrganisationRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/** @return array<string, mixed> */
public function rules(): array
{
return [
'name' => ['sometimes', 'string', 'max:255'],
'slug' => [
'sometimes', 'string', 'max:255', 'regex:/^[a-z0-9-]+$/',
Rule::unique('organisations', 'slug')->ignore($this->route('organisation')),
],
'billing_status' => ['sometimes', new Enum(BillingStatus::class)],
'settings' => ['nullable', 'array'],
];
}
}

View File

@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\Admin;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
final class AdminUpdateUserRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/** @return array<string, mixed> */
public function rules(): array
{
return [
'first_name' => ['sometimes', 'string', 'max:255'],
'last_name' => ['sometimes', 'string', 'max:255'],
'email' => [
'sometimes', 'string', 'email', 'max:255',
Rule::unique('users', 'email')->ignore($this->route('user')),
],
'timezone' => ['sometimes', 'string', 'timezone'],
'locale' => ['sometimes', 'string', Rule::in(['nl', 'en'])],
'roles' => ['nullable', 'array'],
'roles.*' => ['string', Rule::in(['super_admin', 'support_agent'])],
];
}
}

View File

@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace App\Http\Resources\Admin;
use App\Enums\BillingStatus;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
final class AdminOrganisationResource extends JsonResource
{
public function toArray(Request $request): array
{
$billingStatus = BillingStatus::tryFrom($this->billing_status);
return [
'id' => $this->id,
'name' => $this->name,
'slug' => $this->slug,
'billing_status' => $this->billing_status,
'billing_status_label' => $billingStatus?->label(),
'settings' => $this->settings,
'events_count' => $this->whenCounted('events'),
'users_count' => $this->whenCounted('users'),
'total_persons' => $this->when(isset($this->total_persons), $this->total_persons),
'created_at' => $this->created_at->toIso8601String(),
'updated_at' => $this->updated_at->toIso8601String(),
'deleted_at' => $this->deleted_at?->toIso8601String(),
];
}
}

View File

@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace App\Http\Resources\Admin;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
final class AdminUserResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'first_name' => $this->first_name,
'last_name' => $this->last_name,
'full_name' => $this->full_name,
'email' => $this->email,
'avatar' => $this->avatar,
'timezone' => $this->timezone,
'locale' => $this->locale,
'email_verified_at' => $this->email_verified_at?->toIso8601String(),
'created_at' => $this->created_at->toIso8601String(),
'roles' => $this->getRoleNames()->values()->all(),
'is_super_admin' => $this->hasRole('super_admin'),
'organisations' => $this->whenLoaded('organisations', fn () =>
$this->organisations->map(fn ($org) => [
'id' => $org->id,
'name' => $org->name,
'slug' => $org->slug,
'role' => $org->pivot->role,
])
),
];
}
}

View File

@@ -0,0 +1,92 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Models\User;
use Illuminate\Support\Facades\Cache;
final class ImpersonationService
{
/**
* Start impersonating a target user.
*
* @return array{token: string, user: User, admin_id: string}
*/
public function start(User $admin, User $targetUser): array
{
if (! $admin->hasRole('super_admin')) {
abort(403, 'Only super admins can impersonate users.');
}
if ($targetUser->hasRole('super_admin')) {
abort(403, 'Cannot impersonate another super admin.');
}
$tokenName = 'impersonation-by-' . $admin->id;
$newToken = $targetUser->createToken($tokenName);
Cache::put(
"impersonation:{$newToken->accessToken->id}",
$admin->id,
now()->addHours(4),
);
activity('admin')
->causedBy($admin)
->performedOn($targetUser)
->event('admin.impersonation.started')
->withProperties([
'admin_id' => $admin->id,
'target_user_id' => $targetUser->id,
])
->log('Started impersonating user ' . $targetUser->full_name);
return [
'token' => $newToken->plainTextToken,
'user' => $targetUser,
'admin_id' => $admin->id,
];
}
/**
* Stop impersonation and return the original admin.
*/
public function stop(User $currentUser): User
{
$currentToken = $currentUser->currentAccessToken();
if (! $currentToken || ! str_starts_with($currentToken->name, 'impersonation-by-')) {
abort(400, 'No active impersonation session.');
}
$adminId = Cache::get("impersonation:{$currentToken->id}");
$admin = $adminId ? User::find($adminId) : null;
if (! $admin) {
// Fallback: extract admin ID from token name
$admin = User::find(str_replace('impersonation-by-', '', $currentToken->name));
}
activity('admin')
->causedBy($admin ?? $currentUser)
->performedOn($currentUser)
->event('admin.impersonation.stopped')
->withProperties([
'admin_id' => $admin?->id,
'impersonated_user_id' => $currentUser->id,
])
->log('Stopped impersonating user ' . $currentUser->full_name);
Cache::forget("impersonation:{$currentToken->id}");
$currentToken->delete();
if (! $admin) {
abort(400, 'Could not resolve original admin session.');
}
return $admin;
}
}

View File

@@ -34,6 +34,7 @@ return Application::configure(basePath: dirname(__DIR__))
$middleware->alias([
'portal.token' => \App\Http\Middleware\PortalTokenMiddleware::class,
'role' => \Spatie\Permission\Middleware\RoleMiddleware::class,
]);
})
->withExceptions(function (Exceptions $exceptions): void {

View File

@@ -36,6 +36,11 @@ use App\Http\Controllers\Api\V1\PasswordResetController;
use App\Http\Controllers\Api\V1\PortalMeController;
use App\Http\Controllers\Api\V1\Portal\PortalShiftController;
use App\Http\Controllers\Api\V1\UserOrganisationTagController;
use App\Http\Controllers\Api\V1\Admin\AdminOrganisationController;
use App\Http\Controllers\Api\V1\Admin\AdminUserController;
use App\Http\Controllers\Api\V1\Admin\AdminStatsController;
use App\Http\Controllers\Api\V1\Admin\AdminActivityLogController;
use App\Http\Controllers\Api\V1\Admin\AdminImpersonationController;
use App\Models\FestivalSection;
use App\Models\Organisation;
use Illuminate\Support\Facades\Gate;
@@ -78,8 +83,33 @@ Route::post('public/check-email', CheckEmailController::class)->middleware('thro
Route::post('events/{event}/volunteer-register', VolunteerRegistrationController::class)->middleware('throttle:5,1');
Route::post('portal/token-auth', [PortalTokenController::class, 'auth'])->middleware('throttle:10,1');
// Platform Admin routes
Route::prefix('admin')
->middleware(['auth:sanctum', 'role:super_admin'])
->name('admin.')
->group(function () {
// Organisations
Route::apiResource('organisations', AdminOrganisationController::class);
// Users
Route::apiResource('users', AdminUserController::class)
->except(['store']);
// Platform statistics
Route::get('stats', [AdminStatsController::class, 'index']);
// Activity log
Route::get('activity-log', [AdminActivityLogController::class, 'index']);
// Impersonation (start)
Route::post('impersonate/{user}', [AdminImpersonationController::class, 'start']);
});
// Protected routes
Route::middleware('auth:sanctum')->group(function () {
// Impersonation (stop — accessible by impersonated user, not just super_admin)
Route::post('admin/stop-impersonation', [AdminImpersonationController::class, 'stop']);
// Auth
Route::get('auth/me', MeController::class);
Route::post('auth/logout', LogoutController::class);

View File

@@ -0,0 +1,124 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Api\V1\Admin;
use App\Models\User;
use Database\Seeders\RoleSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Laravel\Sanctum\Sanctum;
use Spatie\Activitylog\Models\Activity;
use Tests\TestCase;
class AdminImpersonationTest extends TestCase
{
use RefreshDatabase;
private User $superAdmin;
private User $targetUser;
private User $otherSuperAdmin;
protected function setUp(): void
{
parent::setUp();
$this->seed(RoleSeeder::class);
$this->superAdmin = User::factory()->create();
$this->superAdmin->assignRole('super_admin');
$this->targetUser = User::factory()->create();
$this->otherSuperAdmin = User::factory()->create();
$this->otherSuperAdmin->assignRole('super_admin');
}
// ─── Start ───────────────────────────────────────────────
public function test_start_creates_token_for_target_user(): void
{
Sanctum::actingAs($this->superAdmin);
$response = $this->postJson("/api/v1/admin/impersonate/{$this->targetUser->id}");
$response->assertOk();
$response->assertJsonStructure([
'data' => ['token', 'user' => ['id', 'email'], 'admin_id'],
]);
$response->assertJsonPath('data.user.id', $this->targetUser->id);
$response->assertJsonPath('data.admin_id', $this->superAdmin->id);
$this->assertDatabaseHas('personal_access_tokens', [
'tokenable_id' => $this->targetUser->id,
'name' => 'impersonation-by-' . $this->superAdmin->id,
]);
}
public function test_start_denied_for_non_super_admin(): void
{
Sanctum::actingAs($this->targetUser);
$response = $this->postJson("/api/v1/admin/impersonate/{$this->targetUser->id}");
$response->assertForbidden();
}
public function test_start_denied_when_target_is_super_admin(): void
{
Sanctum::actingAs($this->superAdmin);
$response = $this->postJson("/api/v1/admin/impersonate/{$this->otherSuperAdmin->id}");
$response->assertForbidden();
}
// ─── Stop ────────────────────────────────────────────────
public function test_stop_deletes_impersonation_token(): void
{
// Start impersonation
Sanctum::actingAs($this->superAdmin);
$startResponse = $this->postJson("/api/v1/admin/impersonate/{$this->targetUser->id}");
$token = $startResponse->json('data.token');
// Reset auth state so the Bearer token takes effect
$this->app['auth']->forgetGuards();
$response = $this->withHeader('Authorization', "Bearer {$token}")
->postJson('/api/v1/admin/stop-impersonation');
$response->assertOk();
$response->assertJsonPath('data.user.id', $this->superAdmin->id);
$this->assertDatabaseMissing('personal_access_tokens', [
'tokenable_id' => $this->targetUser->id,
'name' => 'impersonation-by-' . $this->superAdmin->id,
]);
}
// ─── Activity Log ────────────────────────────────────────
public function test_activity_log_records_start_and_stop(): void
{
Sanctum::actingAs($this->superAdmin);
$startResponse = $this->postJson("/api/v1/admin/impersonate/{$this->targetUser->id}");
$token = $startResponse->json('data.token');
$this->assertDatabaseHas('activity_log', [
'event' => 'admin.impersonation.started',
'causer_id' => $this->superAdmin->id,
'subject_id' => $this->targetUser->id,
]);
// Reset auth state so the Bearer token takes effect
$this->app['auth']->forgetGuards();
$this->withHeader('Authorization', "Bearer {$token}")
->postJson('/api/v1/admin/stop-impersonation');
$this->assertDatabaseHas('activity_log', [
'event' => 'admin.impersonation.stopped',
'subject_id' => $this->targetUser->id,
]);
}
}

View File

@@ -0,0 +1,149 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Api\V1\Admin;
use App\Models\Event;
use App\Models\Organisation;
use App\Models\Person;
use App\Models\User;
use Database\Seeders\RoleSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Laravel\Sanctum\Sanctum;
use Tests\TestCase;
class AdminOrganisationControllerTest extends TestCase
{
use RefreshDatabase;
private User $superAdmin;
private User $orgAdmin;
private Organisation $organisation;
protected function setUp(): void
{
parent::setUp();
$this->seed(RoleSeeder::class);
$this->superAdmin = User::factory()->create();
$this->superAdmin->assignRole('super_admin');
$this->orgAdmin = User::factory()->create();
$this->organisation = Organisation::factory()->create(['billing_status' => 'active']);
$this->organisation->users()->attach($this->orgAdmin, ['role' => 'org_admin']);
}
// ─── Index ───────────────────────────────────────────────
public function test_index_returns_all_organisations_for_super_admin(): void
{
Organisation::factory()->count(3)->create();
Sanctum::actingAs($this->superAdmin);
$response = $this->getJson('/api/v1/admin/organisations');
$response->assertOk();
$response->assertJsonStructure([
'data' => [['id', 'name', 'slug', 'billing_status', 'events_count', 'users_count']],
]);
// 1 from setUp + 3 created = 4
$this->assertCount(4, $response->json('data'));
}
public function test_index_denied_for_org_admin(): void
{
Sanctum::actingAs($this->orgAdmin);
$response = $this->getJson('/api/v1/admin/organisations');
$response->assertForbidden();
}
public function test_index_denied_for_unauthenticated(): void
{
$response = $this->getJson('/api/v1/admin/organisations');
$response->assertUnauthorized();
}
public function test_search_by_name(): void
{
Organisation::factory()->create(['name' => 'Festival Corp']);
Organisation::factory()->create(['name' => 'Music Events BV']);
Sanctum::actingAs($this->superAdmin);
$response = $this->getJson('/api/v1/admin/organisations?search=Festival');
$response->assertOk();
$this->assertCount(1, $response->json('data'));
$response->assertJsonPath('data.0.name', 'Festival Corp');
}
public function test_filter_by_billing_status(): void
{
Organisation::factory()->create(['billing_status' => 'trial']);
Organisation::factory()->create(['billing_status' => 'suspended']);
Sanctum::actingAs($this->superAdmin);
$response = $this->getJson('/api/v1/admin/organisations?billing_status=trial');
$response->assertOk();
foreach ($response->json('data') as $org) {
$this->assertEquals('trial', $org['billing_status']);
}
}
// ─── Show ────────────────────────────────────────────────
public function test_show_returns_organisation_with_counts(): void
{
$event = Event::factory()->create(['organisation_id' => $this->organisation->id]);
Sanctum::actingAs($this->superAdmin);
$response = $this->getJson("/api/v1/admin/organisations/{$this->organisation->id}");
$response->assertOk();
$response->assertJsonPath('data.id', $this->organisation->id);
$response->assertJsonPath('data.events_count', 1);
$response->assertJsonPath('data.users_count', 1);
$response->assertJsonStructure([
'data' => ['id', 'name', 'slug', 'billing_status', 'billing_status_label', 'events_count', 'users_count', 'total_persons'],
]);
}
// ─── Update ──────────────────────────────────────────────
public function test_update_changes_billing_status(): void
{
Sanctum::actingAs($this->superAdmin);
$response = $this->putJson("/api/v1/admin/organisations/{$this->organisation->id}", [
'billing_status' => 'suspended',
]);
$response->assertOk();
$response->assertJsonPath('data.billing_status', 'suspended');
$this->assertDatabaseHas('organisations', [
'id' => $this->organisation->id,
'billing_status' => 'suspended',
]);
}
// ─── Destroy ─────────────────────────────────────────────
public function test_destroy_soft_deletes(): void
{
Sanctum::actingAs($this->superAdmin);
$response = $this->deleteJson("/api/v1/admin/organisations/{$this->organisation->id}");
$response->assertNoContent();
$this->assertSoftDeleted('organisations', ['id' => $this->organisation->id]);
}
}

View File

@@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Api\V1\Admin;
use App\Models\Event;
use App\Models\Organisation;
use App\Models\User;
use Database\Seeders\RoleSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Laravel\Sanctum\Sanctum;
use Tests\TestCase;
class AdminStatsControllerTest extends TestCase
{
use RefreshDatabase;
private User $superAdmin;
private User $regularUser;
protected function setUp(): void
{
parent::setUp();
$this->seed(RoleSeeder::class);
$this->superAdmin = User::factory()->create();
$this->superAdmin->assignRole('super_admin');
$this->regularUser = User::factory()->create();
}
public function test_returns_aggregate_counts(): void
{
$org = Organisation::factory()->create(['billing_status' => 'active']);
Event::factory()->count(2)->create([
'organisation_id' => $org->id,
'status' => 'draft',
]);
Sanctum::actingAs($this->superAdmin);
$response = $this->getJson('/api/v1/admin/stats');
$response->assertOk();
$response->assertJsonStructure([
'data' => [
'organisations' => ['total', 'by_billing_status'],
'events' => ['total', 'by_status'],
'users' => ['total', 'verified'],
'persons' => ['total'],
],
]);
$this->assertGreaterThanOrEqual(1, $response->json('data.organisations.total'));
$this->assertGreaterThanOrEqual(2, $response->json('data.events.total'));
}
public function test_denied_for_non_super_admin(): void
{
Sanctum::actingAs($this->regularUser);
$response = $this->getJson('/api/v1/admin/stats');
$response->assertForbidden();
}
}

View File

@@ -0,0 +1,151 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Api\V1\Admin;
use App\Models\Organisation;
use App\Models\User;
use Database\Seeders\RoleSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Laravel\Sanctum\Sanctum;
use Tests\TestCase;
class AdminUserControllerTest extends TestCase
{
use RefreshDatabase;
private User $superAdmin;
private User $regularUser;
private Organisation $organisation;
protected function setUp(): void
{
parent::setUp();
$this->seed(RoleSeeder::class);
$this->superAdmin = User::factory()->create();
$this->superAdmin->assignRole('super_admin');
$this->organisation = Organisation::factory()->create();
$this->regularUser = User::factory()->create();
$this->organisation->users()->attach($this->regularUser, ['role' => 'org_admin']);
}
// ─── Index ───────────────────────────────────────────────
public function test_index_returns_all_users_with_organisations(): void
{
Sanctum::actingAs($this->superAdmin);
$response = $this->getJson('/api/v1/admin/users');
$response->assertOk();
$response->assertJsonStructure([
'data' => [['id', 'first_name', 'last_name', 'full_name', 'email', 'is_super_admin', 'organisations']],
]);
// superAdmin + regularUser = 2
$this->assertCount(2, $response->json('data'));
}
public function test_index_denied_for_non_super_admin(): void
{
Sanctum::actingAs($this->regularUser);
$response = $this->getJson('/api/v1/admin/users');
$response->assertForbidden();
}
public function test_search_by_email(): void
{
$user = User::factory()->create(['email' => 'searchable@crewli.test']);
Sanctum::actingAs($this->superAdmin);
$response = $this->getJson('/api/v1/admin/users?search=searchable@crewli');
$response->assertOk();
$this->assertCount(1, $response->json('data'));
$response->assertJsonPath('data.0.email', 'searchable@crewli.test');
}
public function test_filter_by_organisation_id(): void
{
$otherOrg = Organisation::factory()->create();
$otherUser = User::factory()->create();
$otherOrg->users()->attach($otherUser, ['role' => 'org_member']);
Sanctum::actingAs($this->superAdmin);
$response = $this->getJson("/api/v1/admin/users?organisation_id={$this->organisation->id}");
$response->assertOk();
$this->assertCount(1, $response->json('data'));
$response->assertJsonPath('data.0.id', $this->regularUser->id);
}
// ─── Show ────────────────────────────────────────────────
public function test_show_returns_user_with_org_memberships(): void
{
Sanctum::actingAs($this->superAdmin);
$response = $this->getJson("/api/v1/admin/users/{$this->regularUser->id}");
$response->assertOk();
$response->assertJsonPath('data.id', $this->regularUser->id);
$response->assertJsonPath('data.organisations.0.id', $this->organisation->id);
$response->assertJsonPath('data.organisations.0.role', 'org_admin');
}
// ─── Update ──────────────────────────────────────────────
public function test_update_changes_name_and_email(): void
{
Sanctum::actingAs($this->superAdmin);
$response = $this->putJson("/api/v1/admin/users/{$this->regularUser->id}", [
'first_name' => 'Updated',
'last_name' => 'Name',
'email' => 'updated@crewli.test',
]);
$response->assertOk();
$response->assertJsonPath('data.first_name', 'Updated');
$response->assertJsonPath('data.email', 'updated@crewli.test');
}
public function test_update_can_assign_super_admin_role(): void
{
Sanctum::actingAs($this->superAdmin);
$response = $this->putJson("/api/v1/admin/users/{$this->regularUser->id}", [
'roles' => ['super_admin'],
]);
$response->assertOk();
$this->assertTrue($this->regularUser->fresh()->hasRole('super_admin'));
}
// ─── Destroy ─────────────────────────────────────────────
public function test_destroy_soft_deletes_and_revokes_tokens(): void
{
$this->regularUser->createToken('test-token');
$this->assertDatabaseHas('personal_access_tokens', [
'tokenable_id' => $this->regularUser->id,
]);
Sanctum::actingAs($this->superAdmin);
$response = $this->deleteJson("/api/v1/admin/users/{$this->regularUser->id}");
$response->assertNoContent();
$this->assertSoftDeleted('users', ['id' => $this->regularUser->id]);
$this->assertDatabaseMissing('personal_access_tokens', [
'tokenable_id' => $this->regularUser->id,
]);
}
}

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`)