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(),
]);
}
}

View File

@@ -0,0 +1,66 @@
<?php
namespace App\Http\Middleware;
use App\Support\ApiError;
use Closure;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Laravel\Sanctum\PersonalAccessToken;
use Symfony\Component\HttpFoundation\Response;
class EnsureSupportToken
{
/**
* Handle an incoming request.
*/
public function handle(Request $request, Closure $next): JsonResponse|Response
{
$user = $request->user();
if (! $user) {
return $this->unauthorizedResponse('Unauthenticated request.');
}
$accessToken = $user->currentAccessToken();
if (! $accessToken instanceof PersonalAccessToken) {
return $this->unauthorizedResponse('Missing personal access token context.');
}
if (! $user->isSuperAdmin()) {
return $this->forbiddenResponse('Only super administrators may access support APIs.');
}
if (! $accessToken->can('support-admin') && ! $accessToken->can('super-admin')) {
return $this->forbiddenResponse('Access token does not include the support-admin ability.');
}
$request->attributes->set('support_token_id', $accessToken->id);
Auth::shouldUse('sanctum');
return $next($request);
}
private function unauthorizedResponse(string $message): JsonResponse
{
return ApiError::response(
'unauthenticated',
'Unauthenticated',
$message,
Response::HTTP_UNAUTHORIZED
);
}
private function forbiddenResponse(string $message): JsonResponse
{
return ApiError::response(
'support_forbidden',
'Forbidden',
$message,
Response::HTTP_FORBIDDEN
);
}
}

View File

@@ -0,0 +1,36 @@
<?php
namespace App\Http\Requests\Support;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
class SupportGuestPolicyRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'guest_downloads_enabled' => ['sometimes', 'boolean'],
'guest_sharing_enabled' => ['sometimes', 'boolean'],
'guest_upload_visibility' => ['sometimes', 'string'],
'per_device_upload_limit' => ['sometimes', 'integer', 'min:0'],
'join_token_failure_limit' => ['sometimes', 'integer', 'min:1'],
'join_token_failure_decay_minutes' => ['sometimes', 'integer', 'min:1'],
'join_token_access_limit' => ['sometimes', 'integer', 'min:0'],
'join_token_access_decay_minutes' => ['sometimes', 'integer', 'min:1'],
'join_token_download_limit' => ['sometimes', 'integer', 'min:0'],
'join_token_download_decay_minutes' => ['sometimes', 'integer', 'min:1'],
'join_token_ttl_hours' => ['sometimes', 'integer', 'min:0'],
'share_link_ttl_hours' => ['sometimes', 'integer', 'min:1'],
'guest_notification_ttl_hours' => ['nullable', 'integer', 'min:1'],
];
}
}

View File

@@ -0,0 +1,24 @@
<?php
namespace App\Http\Requests\Support;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
class SupportResourceRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'data' => ['required', 'array'],
];
}
}

View File

@@ -0,0 +1,52 @@
<?php
namespace App\Http\Requests\Support;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
class SupportTokenRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'login' => ['required', 'string'],
'password' => ['required', 'string'],
'abilities' => ['sometimes', 'array'],
'abilities.*' => ['string'],
];
}
/**
* @return array{email?: string, username?: string, password: string, abilities?: array<int, string>}
*/
public function credentials(): array
{
$login = $this->string('login')->trim()->value();
$credentials = [
'password' => $this->string('password')->value(),
];
if (filter_var($login, FILTER_VALIDATE_EMAIL)) {
$credentials['email'] = $login;
} else {
$credentials['username'] = $login;
}
$abilities = $this->input('abilities');
if (is_array($abilities) && $abilities !== []) {
$credentials['abilities'] = array_values(array_filter($abilities, 'is_string'));
}
return $credentials;
}
}

View File

@@ -0,0 +1,30 @@
<?php
namespace App\Http\Requests\Support;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
class SupportWatermarkSettingsRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'asset' => ['sometimes', 'string'],
'position' => ['sometimes', 'string'],
'opacity' => ['sometimes', 'numeric', 'min:0', 'max:1'],
'scale' => ['sometimes', 'numeric', 'min:0.05', 'max:1'],
'padding' => ['sometimes', 'integer', 'min:0'],
'offset_x' => ['sometimes', 'integer', 'min:-500', 'max:500'],
'offset_y' => ['sometimes', 'integer', 'min:-500', 'max:500'],
];
}
}

View File

@@ -0,0 +1,26 @@
<?php
namespace App\Http\Requests\Support\Tenant;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
class SupportTenantAddPackageRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'package_id' => ['required', 'integer'],
'expires_at' => ['nullable', 'date'],
'reason' => ['nullable', 'string'],
];
}
}

View File

@@ -0,0 +1,25 @@
<?php
namespace App\Http\Requests\Support\Tenant;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
class SupportTenantScheduleDeletionRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'pending_deletion_at' => ['required', 'date', 'after:now'],
'send_warning' => ['sometimes', 'boolean'],
];
}
}

View File

@@ -0,0 +1,25 @@
<?php
namespace App\Http\Requests\Support\Tenant;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
class SupportTenantSetGracePeriodRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'grace_period_ends_at' => ['required', 'date', 'after_or_equal:now'],
'note' => ['nullable', 'string'],
];
}
}

View File

@@ -0,0 +1,26 @@
<?php
namespace App\Http\Requests\Support\Tenant;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
class SupportTenantUpdateLimitsRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'max_photos_per_event' => ['required', 'integer', 'min:0'],
'max_storage_mb' => ['required', 'integer', 'min:0'],
'note' => ['nullable', 'string'],
];
}
}

View File

@@ -0,0 +1,25 @@
<?php
namespace App\Http\Requests\Support\Tenant;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
class SupportTenantUpdateSubscriptionRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'subscription_expires_at' => ['nullable', 'date'],
'note' => ['nullable', 'string'],
];
}
}

View File

@@ -0,0 +1,51 @@
<?php
namespace App\Support;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class SupportApiAuthorizer
{
public static function authorizeResource(Request $request, string $resource, string $action): ?JsonResponse
{
$abilities = SupportApiRegistry::abilitiesFor($resource, $action);
return self::authorizeAbilities($request, $abilities, $action);
}
/**
* @param array<int, string> $abilities
*/
public static function authorizeAbilities(Request $request, array $abilities, string $actionLabel = 'resource'): ?JsonResponse
{
if ($abilities === []) {
return null;
}
$token = $request->user()?->currentAccessToken();
if (! $token) {
return ApiError::response(
'unauthenticated',
'Unauthenticated',
'Missing access token for support request.',
401
);
}
foreach ($abilities as $ability) {
if (! $token->can($ability)) {
return ApiError::response(
'forbidden',
'Forbidden',
"Missing required ability for support {$actionLabel}.",
403,
['required' => $abilities]
);
}
}
return null;
}
}

View File

@@ -0,0 +1,118 @@
<?php
namespace App\Support;
class SupportApiRegistry
{
/**
* @return array<string, array<string, mixed>>
*/
public static function resources(): array
{
return config('support-api.resources', []);
}
/**
* @return array<string, mixed>|null
*/
public static function get(string $resource): ?array
{
$resources = self::resources();
return $resources[$resource] ?? null;
}
/**
* @return array<int, string>
*/
public static function resourceKeys(): array
{
return array_keys(self::resources());
}
public static function resourcePattern(): string
{
$keys = self::resourceKeys();
if ($keys === []) {
return '.*';
}
$escaped = array_map(static fn (string $key): string => preg_quote($key, '/'), $keys);
return implode('|', $escaped);
}
/**
* @return array<int, string>
*/
public static function abilitiesFor(string $resource, string $action): array
{
$config = self::get($resource);
if (! $config) {
return [];
}
$abilities = $config['abilities'][$action] ?? null;
if (is_array($abilities) && $abilities !== []) {
return $abilities;
}
return match ($action) {
'read' => ['support:read'],
'write' => ['support:write'],
'actions' => ['support:actions'],
default => [],
};
}
public static function isReadOnly(string $resource): bool
{
$config = self::get($resource);
return (bool) ($config['read_only'] ?? false);
}
public static function allowsMutation(string $resource, string $action): bool
{
if (self::isReadOnly($resource)) {
return false;
}
$config = self::get($resource);
$mutations = $config['mutations'] ?? null;
if (! is_array($mutations)) {
return true;
}
return (bool) ($mutations[$action] ?? false);
}
/**
* @return array<int, string>
*/
public static function searchFields(string $resource): array
{
$config = self::get($resource);
$fields = $config['search'] ?? [];
return is_array($fields) ? $fields : [];
}
/**
* @return array<int, string>
*/
public static function withRelations(string $resource): array
{
$config = self::get($resource);
$relations = $config['with'] ?? [];
return is_array($relations) ? $relations : [];
}
}