Add support API scaffold
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled

This commit is contained in:
Codex Agent
2026-01-28 13:52:47 +01:00
parent 75c4dbd1f0
commit 53a6500e6a
23 changed files with 2381 additions and 1 deletions

View File

@@ -0,0 +1,53 @@
<?php
namespace App\Http\Controllers\Api\Support;
use App\Http\Controllers\Controller;
use App\Http\Requests\Support\SupportGuestPolicyRequest;
use App\Models\GuestPolicySetting;
use App\Services\Audit\SuperAdminAuditLogger;
use App\Support\SupportApiAuthorizer;
use Illuminate\Http\JsonResponse;
class SupportGuestPolicyController extends Controller
{
public function show(): JsonResponse
{
if ($response = SupportApiAuthorizer::authorizeAbilities(request(), ['support:settings'], 'settings')) {
return $response;
}
$settings = GuestPolicySetting::current();
return response()->json([
'data' => $settings,
]);
}
public function update(SupportGuestPolicyRequest $request): JsonResponse
{
if ($response = SupportApiAuthorizer::authorizeAbilities($request, ['support:settings'], 'settings')) {
return $response;
}
$settings = GuestPolicySetting::query()->firstOrNew(['id' => 1]);
$settings->fill($request->validated());
$settings->save();
$changed = $settings->getChanges();
if ($changed !== []) {
app(SuperAdminAuditLogger::class)->record(
'guest_policy.updated',
$settings,
SuperAdminAuditLogger::fieldsMetadata(array_keys($changed)),
source: static::class
);
}
return response()->json([
'data' => $settings->refresh(),
]);
}
}

View File

@@ -0,0 +1,308 @@
<?php
namespace App\Http\Controllers\Api\Support;
use App\Http\Controllers\Controller;
use App\Http\Requests\Support\SupportResourceRequest;
use App\Support\ApiError;
use App\Support\SupportApiAuthorizer;
use App\Support\SupportApiRegistry;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Schema;
class SupportResourceController extends Controller
{
public function index(Request $request, string $resource): JsonResponse
{
if ($response = SupportApiAuthorizer::authorizeResource($request, $resource, 'read')) {
return $response;
}
$config = SupportApiRegistry::get($resource);
if (! $config) {
return $this->resourceNotFoundResponse($resource);
}
$modelClass = $config['model'];
/** @var Builder $query */
$query = $modelClass::query();
$relations = SupportApiRegistry::withRelations($resource);
if ($relations !== []) {
$query->with($relations);
}
$this->applySearch($request, $query, $resource);
$this->applySorting($request, $query, $resource);
$perPage = $this->resolvePerPage($request);
$paginator = $query->paginate($perPage);
return response()->json([
'data' => $paginator->items(),
'meta' => [
'page' => $paginator->currentPage(),
'per_page' => $paginator->perPage(),
'total' => $paginator->total(),
'last_page' => $paginator->lastPage(),
],
]);
}
public function show(Request $request, string $resource, string $record): JsonResponse
{
if ($response = SupportApiAuthorizer::authorizeResource($request, $resource, 'read')) {
return $response;
}
$model = $this->resolveRecord($resource, $record);
if (! $model) {
return $this->resourceNotFoundResponse($resource, $record);
}
return response()->json([
'data' => $model,
]);
}
public function store(SupportResourceRequest $request, string $resource): JsonResponse
{
if ($response = SupportApiAuthorizer::authorizeResource($request, $resource, 'write')) {
return $response;
}
if (! SupportApiRegistry::allowsMutation($resource, 'create')) {
return $this->mutationNotAllowedResponse($resource, 'create');
}
$config = SupportApiRegistry::get($resource);
if (! $config) {
return $this->resourceNotFoundResponse($resource);
}
$modelClass = $config['model'];
/** @var Model $model */
$model = new $modelClass;
$payload = $this->filteredPayload($request, $model);
if ($payload === []) {
return $this->emptyPayloadResponse($resource);
}
$record = $modelClass::query()->create($payload);
return response()->json([
'data' => $record,
], 201);
}
public function update(SupportResourceRequest $request, string $resource, string $record): JsonResponse
{
if ($response = SupportApiAuthorizer::authorizeResource($request, $resource, 'write')) {
return $response;
}
if (! SupportApiRegistry::allowsMutation($resource, 'update')) {
return $this->mutationNotAllowedResponse($resource, 'update');
}
$model = $this->resolveRecord($resource, $record);
if (! $model) {
return $this->resourceNotFoundResponse($resource, $record);
}
$payload = $this->filteredPayload($request, $model);
if ($payload === []) {
return $this->emptyPayloadResponse($resource);
}
$model->fill($payload);
$model->save();
return response()->json([
'data' => $model->refresh(),
]);
}
public function destroy(Request $request, string $resource, string $record): JsonResponse
{
if ($response = SupportApiAuthorizer::authorizeResource($request, $resource, 'write')) {
return $response;
}
if (! SupportApiRegistry::allowsMutation($resource, 'delete')) {
return $this->mutationNotAllowedResponse($resource, 'delete');
}
$model = $this->resolveRecord($resource, $record);
if (! $model) {
return $this->resourceNotFoundResponse($resource, $record);
}
$model->delete();
return response()->json(['ok' => true]);
}
private function resolveRecord(string $resource, string $record): ?Model
{
$config = SupportApiRegistry::get($resource);
if (! $config) {
return null;
}
$modelClass = $config['model'];
$query = $modelClass::query();
if (is_numeric($record)) {
return $query->find($record);
}
$keyName = (new $modelClass)->getKeyName();
return $query->where($keyName, $record)->first();
}
private function filteredPayload(SupportResourceRequest $request, Model $model): array
{
$payload = $request->validated('data');
if (! is_array($payload)) {
return [];
}
$fillable = $model->getFillable();
if ($fillable === [] && method_exists($model, 'getGuarded') && $model->getGuarded() !== ['*']) {
$columns = Schema::getColumnListing($model->getTable());
return Arr::only($payload, $columns);
}
if ($fillable === []) {
return [];
}
return Arr::only($payload, $fillable);
}
private function applySearch(Request $request, Builder $query, string $resource): void
{
$term = $request->string('search')->trim()->value();
if ($term === '') {
return;
}
$fields = SupportApiRegistry::searchFields($resource);
if ($fields === []) {
return;
}
$columns = Schema::getColumnListing($query->getModel()->getTable());
$fields = array_values(array_intersect($fields, $columns));
if ($fields === []) {
return;
}
$query->where(function (Builder $builder) use ($fields, $term): void {
foreach ($fields as $field) {
if ($field === 'id' && is_numeric($term)) {
$builder->orWhere($field, (int) $term);
} else {
$builder->orWhere($field, 'like', "%{$term}%");
}
}
});
}
private function applySorting(Request $request, Builder $query, string $resource): void
{
$sort = $request->string('sort')->trim()->value();
if ($sort === '') {
return;
}
$direction = 'asc';
$field = $sort;
if (str_starts_with($sort, '-')) {
$direction = 'desc';
$field = ltrim($sort, '-');
}
$allowed = SupportApiRegistry::searchFields($resource);
$allowed[] = 'id';
$columns = Schema::getColumnListing($query->getModel()->getTable());
$allowed = array_values(array_intersect($allowed, $columns));
if (! in_array($field, $allowed, true)) {
return;
}
$query->orderBy($field, $direction);
}
private function resolvePerPage(Request $request): int
{
$default = (int) config('support-api.pagination.default_per_page', 50);
$max = (int) config('support-api.pagination.max_per_page', 200);
$perPage = (int) $request->input('per_page', $default);
if ($perPage < 1) {
$perPage = $default;
}
return min($perPage, $max);
}
private function mutationNotAllowedResponse(string $resource, string $action): JsonResponse
{
return ApiError::response(
'support_mutation_not_allowed',
'Mutation Not Allowed',
"{$resource} does not allow {$action} operations in support API.",
403
);
}
private function emptyPayloadResponse(string $resource): JsonResponse
{
return ApiError::response(
'support_invalid_payload',
'Invalid Payload',
"No mutable fields provided for {$resource}.",
422
);
}
private function resourceNotFoundResponse(string $resource, ?string $record = null): JsonResponse
{
$message = $record
? "{$resource} record not found."
: "Support resource {$resource} is not registered.";
return ApiError::response(
'support_resource_not_found',
'Not Found',
$message,
404
);
}
}

View File

@@ -0,0 +1,411 @@
<?php
namespace App\Http\Controllers\Api\Support;
use App\Http\Controllers\Controller;
use App\Http\Requests\Support\Tenant\SupportTenantAddPackageRequest;
use App\Http\Requests\Support\Tenant\SupportTenantScheduleDeletionRequest;
use App\Http\Requests\Support\Tenant\SupportTenantSetGracePeriodRequest;
use App\Http\Requests\Support\Tenant\SupportTenantUpdateLimitsRequest;
use App\Http\Requests\Support\Tenant\SupportTenantUpdateSubscriptionRequest;
use App\Jobs\AnonymizeAccount;
use App\Models\Package;
use App\Models\PackagePurchase;
use App\Models\Tenant;
use App\Models\TenantPackage;
use App\Notifications\InactiveTenantDeletionWarning;
use App\Services\Audit\SuperAdminAuditLogger;
use App\Services\Tenant\TenantLifecycleLogger;
use App\Support\SupportApiAuthorizer;
use Carbon\Carbon;
use Filament\Notifications\Notification;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Notification as NotificationFacade;
class SupportTenantActionsController extends Controller
{
public function activate(Tenant $tenant): JsonResponse
{
if ($response = $this->authorizeAction('tenants', 'actions')) {
return $response;
}
$updated = $tenant->update(['is_active' => true]);
app(TenantLifecycleLogger::class)->record(
$tenant,
'activated',
actor: auth()->user()
);
app(SuperAdminAuditLogger::class)->record(
'tenant.activated',
$tenant,
SuperAdminAuditLogger::fieldsMetadata(['is_active']),
source: static::class
);
return response()->json(['ok' => $updated]);
}
public function deactivate(Tenant $tenant): JsonResponse
{
if ($response = $this->authorizeAction('tenants', 'actions')) {
return $response;
}
$updated = $tenant->update(['is_active' => false]);
app(TenantLifecycleLogger::class)->record(
$tenant,
'deactivated',
actor: auth()->user()
);
app(SuperAdminAuditLogger::class)->record(
'tenant.deactivated',
$tenant,
SuperAdminAuditLogger::fieldsMetadata(['is_active']),
source: static::class
);
return response()->json(['ok' => $updated]);
}
public function suspend(Tenant $tenant): JsonResponse
{
if ($response = $this->authorizeAction('tenants', 'actions')) {
return $response;
}
$updated = $tenant->update(['is_suspended' => true]);
app(TenantLifecycleLogger::class)->record(
$tenant,
'suspended',
actor: auth()->user()
);
app(SuperAdminAuditLogger::class)->record(
'tenant.suspended',
$tenant,
SuperAdminAuditLogger::fieldsMetadata(['is_suspended']),
source: static::class
);
return response()->json(['ok' => $updated]);
}
public function unsuspend(Tenant $tenant): JsonResponse
{
if ($response = $this->authorizeAction('tenants', 'actions')) {
return $response;
}
$updated = $tenant->update(['is_suspended' => false]);
app(TenantLifecycleLogger::class)->record(
$tenant,
'unsuspended',
actor: auth()->user()
);
app(SuperAdminAuditLogger::class)->record(
'tenant.unsuspended',
$tenant,
SuperAdminAuditLogger::fieldsMetadata(['is_suspended']),
source: static::class
);
return response()->json(['ok' => $updated]);
}
public function scheduleDeletion(SupportTenantScheduleDeletionRequest $request, Tenant $tenant): JsonResponse
{
if ($response = $this->authorizeAction('tenants', 'actions')) {
return $response;
}
$plannedDeletion = Carbon::parse($request->string('pending_deletion_at')->value());
$update = [
'pending_deletion_at' => $plannedDeletion,
];
if ($request->boolean('send_warning', true)) {
$email = $tenant->contact_email
?? $tenant->email
?? $tenant->user?->email;
if ($email) {
NotificationFacade::route('mail', $email)
->notify(new InactiveTenantDeletionWarning($tenant, $plannedDeletion));
$update['deletion_warning_sent_at'] = now();
} else {
Notification::make()
->danger()
->title(__('admin.tenants.actions.send_warning_missing_title'))
->body(__('admin.tenants.actions.send_warning_missing_body'))
->send();
}
}
$tenant->forceFill($update)->save();
app(TenantLifecycleLogger::class)->record(
$tenant,
'deletion_scheduled',
[
'pending_deletion_at' => $plannedDeletion->toDateTimeString(),
'send_warning' => $request->boolean('send_warning', true),
],
auth()->user()
);
app(SuperAdminAuditLogger::class)->record(
'tenant.deletion_scheduled',
$tenant,
SuperAdminAuditLogger::fieldsMetadata($request->validated()),
source: static::class
);
return response()->json(['ok' => true]);
}
public function cancelDeletion(Tenant $tenant): JsonResponse
{
if ($response = $this->authorizeAction('tenants', 'actions')) {
return $response;
}
$previous = $tenant->pending_deletion_at?->toDateTimeString();
$tenant->forceFill([
'pending_deletion_at' => null,
'deletion_warning_sent_at' => null,
])->save();
app(TenantLifecycleLogger::class)->record(
$tenant,
'deletion_cancelled',
['pending_deletion_at' => $previous],
auth()->user()
);
app(SuperAdminAuditLogger::class)->record(
'tenant.deletion_cancelled',
$tenant,
SuperAdminAuditLogger::fieldsMetadata(['pending_deletion_at', 'deletion_warning_sent_at']),
source: static::class
);
return response()->json(['ok' => true]);
}
public function anonymize(Tenant $tenant): JsonResponse
{
if ($response = $this->authorizeAction('tenants', 'actions')) {
return $response;
}
AnonymizeAccount::dispatch(null, $tenant->id);
app(TenantLifecycleLogger::class)->record(
$tenant,
'anonymize_requested',
actor: auth()->user()
);
app(SuperAdminAuditLogger::class)->record(
'tenant.anonymize_requested',
$tenant,
SuperAdminAuditLogger::fieldsMetadata([]),
source: static::class
);
return response()->json(['ok' => true]);
}
public function addPackage(SupportTenantAddPackageRequest $request, Tenant $tenant): JsonResponse
{
if ($response = $this->authorizeAction('tenants', 'actions')) {
return $response;
}
$package = Package::query()->find($request->integer('package_id'));
TenantPackage::query()->create([
'tenant_id' => $tenant->id,
'package_id' => $request->integer('package_id'),
'expires_at' => $request->date('expires_at'),
'active' => true,
'price' => $package?->price ?? 0,
'reason' => $request->string('reason')->value(),
]);
PackagePurchase::query()->create([
'tenant_id' => $tenant->id,
'package_id' => $request->integer('package_id'),
'provider' => 'manual',
'provider_id' => 'manual',
'type' => 'reseller_subscription',
'price' => 0,
'metadata' => ['reason' => $request->string('reason')->value() ?: 'manual assignment'],
]);
app(SuperAdminAuditLogger::class)->record(
'tenant.package_added',
$tenant,
SuperAdminAuditLogger::fieldsMetadata($request->validated()),
source: static::class
);
return response()->json(['ok' => true]);
}
public function updateLimits(SupportTenantUpdateLimitsRequest $request, Tenant $tenant): JsonResponse
{
if ($response = $this->authorizeAction('tenants', 'actions')) {
return $response;
}
$before = [
'max_photos_per_event' => $tenant->max_photos_per_event,
'max_storage_mb' => $tenant->max_storage_mb,
];
$tenant->forceFill([
'max_photos_per_event' => $request->integer('max_photos_per_event'),
'max_storage_mb' => $request->integer('max_storage_mb'),
])->save();
$after = [
'max_photos_per_event' => $tenant->max_photos_per_event,
'max_storage_mb' => $tenant->max_storage_mb,
];
app(TenantLifecycleLogger::class)->record(
$tenant,
'limits_updated',
[
'before' => $before,
'after' => $after,
'note' => $request->string('note')->value(),
],
auth()->user()
);
app(SuperAdminAuditLogger::class)->record(
'tenant.limits_updated',
$tenant,
SuperAdminAuditLogger::fieldsMetadata($request->validated()),
source: static::class
);
return response()->json(['ok' => true]);
}
public function updateSubscriptionExpiresAt(SupportTenantUpdateSubscriptionRequest $request, Tenant $tenant): JsonResponse
{
if ($response = $this->authorizeAction('tenants', 'actions')) {
return $response;
}
$before = [
'subscription_expires_at' => optional($tenant->subscription_expires_at)->toDateTimeString(),
];
$tenant->forceFill([
'subscription_expires_at' => $request->date('subscription_expires_at'),
])->save();
$after = [
'subscription_expires_at' => optional($tenant->subscription_expires_at)->toDateTimeString(),
];
app(TenantLifecycleLogger::class)->record(
$tenant,
'subscription_expires_at_updated',
[
'before' => $before,
'after' => $after,
'note' => $request->string('note')->value(),
],
auth()->user()
);
app(SuperAdminAuditLogger::class)->record(
'tenant.subscription_expires_at_updated',
$tenant,
SuperAdminAuditLogger::fieldsMetadata($request->validated()),
source: static::class
);
return response()->json(['ok' => true]);
}
public function setGracePeriod(SupportTenantSetGracePeriodRequest $request, Tenant $tenant): JsonResponse
{
if ($response = $this->authorizeAction('tenants', 'actions')) {
return $response;
}
$tenant->forceFill([
'grace_period_ends_at' => $request->date('grace_period_ends_at'),
])->save();
app(TenantLifecycleLogger::class)->record(
$tenant,
'grace_period_set',
[
'grace_period_ends_at' => optional($tenant->grace_period_ends_at)->toDateTimeString(),
'note' => $request->string('note')->value(),
],
auth()->user()
);
app(SuperAdminAuditLogger::class)->record(
'tenant.grace_period_set',
$tenant,
SuperAdminAuditLogger::fieldsMetadata($request->validated()),
source: static::class
);
return response()->json(['ok' => true]);
}
public function clearGracePeriod(Tenant $tenant): JsonResponse
{
if ($response = $this->authorizeAction('tenants', 'actions')) {
return $response;
}
$previous = $tenant->grace_period_ends_at?->toDateTimeString();
$tenant->forceFill([
'grace_period_ends_at' => null,
])->save();
app(TenantLifecycleLogger::class)->record(
$tenant,
'grace_period_cleared',
[
'grace_period_ends_at' => $previous,
],
auth()->user()
);
app(SuperAdminAuditLogger::class)->record(
'tenant.grace_period_cleared',
$tenant,
SuperAdminAuditLogger::fieldsMetadata(['grace_period_ends_at']),
source: static::class
);
return response()->json(['ok' => true]);
}
private function authorizeAction(string $resource, string $action): ?JsonResponse
{
return SupportApiAuthorizer::authorizeResource(request(), $resource, $action);
}
}

View File

@@ -0,0 +1,103 @@
<?php
namespace App\Http\Controllers\Api\Support;
use App\Http\Controllers\Controller;
use App\Http\Requests\Support\SupportTokenRequest;
use App\Models\User;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\ValidationException;
class SupportTokenController extends Controller
{
public function store(SupportTokenRequest $request): JsonResponse
{
$credentials = $request->credentials();
$query = User::query();
if (isset($credentials['email'])) {
$query->where('email', $credentials['email']);
}
if (isset($credentials['username'])) {
$query->where('username', $credentials['username']);
}
/** @var User|null $user */
$user = $query->first();
if (! $user || ! Hash::check($credentials['password'], (string) $user->password)) {
throw ValidationException::withMessages([
'login' => [trans('auth.failed')],
]);
}
if (! $user->isSuperAdmin()) {
throw ValidationException::withMessages([
'login' => [trans('auth.not_authorized')],
]);
}
$tokenConfig = config('support-api.token');
$defaultAbilities = $tokenConfig['default_abilities'] ?? [];
$abilities = $credentials['abilities'] ?? $defaultAbilities;
if ($abilities !== $defaultAbilities) {
$abilities = array_values(array_intersect($abilities, $defaultAbilities));
}
if (! in_array('support-admin', $abilities, true)) {
$abilities[] = 'support-admin';
}
$tokenName = (string) ($tokenConfig['name'] ?? 'support-api');
$user->tokens()->where('name', $tokenName)->delete();
$token = $user->createToken($tokenName, $abilities);
return response()->json([
'token' => $token->plainTextToken,
'token_type' => 'Bearer',
'abilities' => $abilities,
'user' => Arr::only($user->toArray(), [
'id',
'email',
'name',
'role',
'tenant_id',
]),
]);
}
public function destroy(Request $request): JsonResponse
{
$token = $request->user()?->currentAccessToken();
if ($token) {
$token->delete();
}
return response()->json(['ok' => true]);
}
public function me(Request $request): JsonResponse
{
$user = $request->user();
return response()->json([
'user' => $user ? Arr::only($user->toArray(), [
'id',
'name',
'email',
'role',
'tenant_id',
]) : null,
'abilities' => $user?->currentAccessToken()?->abilities ?? [],
]);
}
}

View File

@@ -0,0 +1,52 @@
<?php
namespace App\Http\Controllers\Api\Support;
use App\Http\Controllers\Controller;
use App\Http\Requests\Support\SupportWatermarkSettingsRequest;
use App\Models\WatermarkSetting;
use App\Services\Audit\SuperAdminAuditLogger;
use App\Support\SupportApiAuthorizer;
use Illuminate\Http\JsonResponse;
class SupportWatermarkSettingsController extends Controller
{
public function show(): JsonResponse
{
if ($response = SupportApiAuthorizer::authorizeAbilities(request(), ['support:settings'], 'settings')) {
return $response;
}
$settings = WatermarkSetting::query()->first();
return response()->json([
'data' => $settings,
]);
}
public function update(SupportWatermarkSettingsRequest $request): JsonResponse
{
if ($response = SupportApiAuthorizer::authorizeAbilities($request, ['support:settings'], 'settings')) {
return $response;
}
$settings = WatermarkSetting::query()->firstOrNew([]);
$settings->fill($request->validated());
$settings->save();
$changed = $settings->getChanges();
if ($changed !== []) {
app(SuperAdminAuditLogger::class)->record(
'watermark_settings.updated',
$settings,
SuperAdminAuditLogger::fieldsMetadata(array_keys($changed)),
source: static::class
);
}
return response()->json([
'data' => $settings->refresh(),
]);
}
}