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

@@ -1 +1 @@
fotospiel-app-jrij
fotospiel-app-v5dd

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 : [];
}
}

View File

@@ -1,6 +1,7 @@
<?php
use App\Http\Middleware\CreditCheckMiddleware;
use App\Http\Middleware\EnsureSupportToken;
use App\Http\Middleware\EnsureTenantAdminToken;
use App\Http\Middleware\EnsureTenantCollaboratorToken;
use App\Http\Middleware\HandleAppearance;
@@ -104,6 +105,7 @@ return Application::configure(basePath: dirname(__DIR__))
'credit.check' => CreditCheckMiddleware::class,
'tenant.admin' => EnsureTenantAdminToken::class,
'tenant.collaborator' => EnsureTenantCollaboratorToken::class,
'support.token' => EnsureSupportToken::class,
]);
$middleware->encryptCookies(except: ['appearance', 'sidebar_state']);

295
config/support-api.php Normal file
View File

@@ -0,0 +1,295 @@
<?php
use App\Models\BlogCategory;
use App\Models\BlogPost;
use App\Models\Coupon;
use App\Models\DataExport;
use App\Models\Emotion;
use App\Models\Event;
use App\Models\EventPurchase;
use App\Models\EventType;
use App\Models\GiftVoucher;
use App\Models\InfrastructureActionLog;
use App\Models\LegalPage;
use App\Models\MediaStorageTarget;
use App\Models\Package;
use App\Models\PackageAddon;
use App\Models\PackagePurchase;
use App\Models\Photo;
use App\Models\PhotoboothSetting;
use App\Models\PurchaseHistory;
use App\Models\RetentionOverride;
use App\Models\SuperAdminActionLog;
use App\Models\Task;
use App\Models\TaskCollection;
use App\Models\Tenant;
use App\Models\TenantAnnouncement;
use App\Models\TenantFeedback;
use App\Models\TenantPackage;
use App\Models\User;
return [
'token' => [
'name' => 'support-api',
'default_abilities' => [
'support-admin',
'support:read',
'support:write',
'support:actions',
'support:billing',
'support:ops',
'support:content',
'support:settings',
'support:infrastructure',
],
],
'pagination' => [
'default_per_page' => 50,
'max_per_page' => 200,
],
'resources' => [
'tenants' => [
'model' => Tenant::class,
'search' => ['name', 'slug', 'contact_email', 'paddle_customer_id'],
'with' => ['user', 'activeResellerPackage'],
'abilities' => [
'read' => ['support:read'],
'write' => ['support:write'],
'actions' => ['support:actions'],
],
'mutations' => [
'create' => false,
'update' => true,
'delete' => false,
],
],
'users' => [
'model' => User::class,
'search' => ['email', 'username', 'name', 'first_name', 'last_name'],
'abilities' => [
'read' => ['support:read'],
'write' => ['support:write'],
],
],
'events' => [
'model' => Event::class,
'search' => ['name', 'slug'],
'abilities' => [
'read' => ['support:read'],
'write' => ['support:write'],
],
],
'event-types' => [
'model' => EventType::class,
'search' => ['name', 'slug'],
'abilities' => [
'read' => ['support:read'],
'write' => ['support:write'],
],
],
'photos' => [
'model' => Photo::class,
'search' => ['id'],
'read_only' => true,
'abilities' => [
'read' => ['support:read'],
],
],
'event-purchases' => [
'model' => EventPurchase::class,
'search' => ['id'],
'read_only' => true,
'abilities' => [
'read' => ['support:billing'],
],
],
'purchases' => [
'model' => PackagePurchase::class,
'search' => ['provider_id'],
'read_only' => true,
'abilities' => [
'read' => ['support:billing'],
],
],
'purchase-histories' => [
'model' => PurchaseHistory::class,
'search' => ['provider_id'],
'read_only' => true,
'abilities' => [
'read' => ['support:billing'],
],
],
'packages' => [
'model' => Package::class,
'search' => ['name', 'slug'],
'abilities' => [
'read' => ['support:billing'],
'write' => ['support:billing'],
],
],
'package-addons' => [
'model' => PackageAddon::class,
'search' => ['name', 'slug'],
'abilities' => [
'read' => ['support:billing'],
'write' => ['support:billing'],
],
],
'tenant-packages' => [
'model' => TenantPackage::class,
'search' => ['id'],
'read_only' => true,
'abilities' => [
'read' => ['support:billing'],
],
],
'coupons' => [
'model' => Coupon::class,
'search' => ['code', 'name'],
'abilities' => [
'read' => ['support:billing'],
'write' => ['support:billing'],
],
],
'gift-vouchers' => [
'model' => GiftVoucher::class,
'search' => ['code', 'email'],
'abilities' => [
'read' => ['support:billing'],
'write' => ['support:billing'],
],
],
'tenant-feedback' => [
'model' => TenantFeedback::class,
'search' => ['email', 'message'],
'abilities' => [
'read' => ['support:read'],
'write' => ['support:write'],
],
'mutations' => [
'create' => false,
'update' => true,
'delete' => false,
],
],
'tenant-announcements' => [
'model' => TenantAnnouncement::class,
'search' => ['title', 'body'],
'abilities' => [
'read' => ['support:read'],
'write' => ['support:write'],
],
],
'media-storage-targets' => [
'model' => MediaStorageTarget::class,
'search' => ['name', 'driver'],
'abilities' => [
'read' => ['support:ops'],
'write' => ['support:ops'],
],
],
'retention-overrides' => [
'model' => RetentionOverride::class,
'search' => ['id'],
'abilities' => [
'read' => ['support:ops'],
'write' => ['support:ops'],
],
],
'data-exports' => [
'model' => DataExport::class,
'search' => ['id'],
'abilities' => [
'read' => ['support:ops'],
'write' => ['support:ops'],
],
'mutations' => [
'create' => true,
'update' => false,
'delete' => false,
],
],
'photobooth-settings' => [
'model' => PhotoboothSetting::class,
'search' => ['label'],
'abilities' => [
'read' => ['support:ops'],
'write' => ['support:ops'],
],
'mutations' => [
'create' => false,
'update' => true,
'delete' => false,
],
],
'legal-pages' => [
'model' => LegalPage::class,
'search' => ['slug', 'title'],
'abilities' => [
'read' => ['support:content'],
'write' => ['support:content'],
],
'mutations' => [
'create' => false,
'update' => true,
'delete' => false,
],
],
'blog-categories' => [
'model' => BlogCategory::class,
'search' => ['name', 'slug'],
'abilities' => [
'read' => ['support:content'],
'write' => ['support:content'],
],
],
'blog-posts' => [
'model' => BlogPost::class,
'search' => ['title', 'slug'],
'abilities' => [
'read' => ['support:content'],
'write' => ['support:content'],
],
],
'emotions' => [
'model' => Emotion::class,
'search' => ['name', 'slug'],
'abilities' => [
'read' => ['support:content'],
'write' => ['support:content'],
],
],
'tasks' => [
'model' => Task::class,
'search' => ['title'],
'abilities' => [
'read' => ['support:read'],
'write' => ['support:write'],
],
],
'task-collections' => [
'model' => TaskCollection::class,
'search' => ['name'],
'abilities' => [
'read' => ['support:read'],
'write' => ['support:write'],
],
],
'super-admin-action-logs' => [
'model' => SuperAdminActionLog::class,
'search' => ['action', 'target_type'],
'read_only' => true,
'abilities' => [
'read' => ['support:infrastructure'],
],
],
'infrastructure-action-logs' => [
'model' => InfrastructureActionLog::class,
'search' => ['action', 'target_type'],
'read_only' => true,
'abilities' => [
'read' => ['support:infrastructure'],
],
],
],
];

View File

@@ -0,0 +1,544 @@
openapi: 3.1.0
info:
title: Fotospiel Support API
version: 1.0.0
description: Support-only management API for super admins.
servers:
- url: /api/v1
security:
- BearerAuth: []
components:
securitySchemes:
BearerAuth:
type: http
scheme: bearer
schemas:
SupportAuthResponse:
type: object
properties:
token:
type: string
token_type:
type: string
abilities:
type: array
items:
type: string
user:
type: object
additionalProperties: true
SupportResourceList:
type: object
properties:
data:
type: array
items:
type: object
additionalProperties: true
meta:
type: object
additionalProperties: true
SupportResourceItem:
type: object
properties:
data:
type: object
additionalProperties: true
SupportResourcePayload:
type: object
properties:
data:
type: object
additionalProperties: true
paths:
/support/auth/token:
post:
summary: Issue a support access token
tags: [support-auth]
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
login:
type: string
password:
type: string
abilities:
type: array
items:
type: string
required: [login, password]
responses:
'200':
description: Token issued
content:
application/json:
schema:
$ref: '#/components/schemas/SupportAuthResponse'
/support/auth/me:
get:
summary: Get current support user
tags: [support-auth]
responses:
'200':
description: Current user
content:
application/json:
schema:
type: object
properties:
user:
type: object
additionalProperties: true
abilities:
type: array
items:
type: string
/support/auth/logout:
post:
summary: Revoke current support token
tags: [support-auth]
responses:
'200':
description: Token revoked
content:
application/json:
schema:
type: object
properties:
ok:
type: boolean
/support/settings/guest-policy:
get:
summary: Get guest policy settings
tags: [support-settings]
responses:
'200':
description: Guest policy
content:
application/json:
schema:
$ref: '#/components/schemas/SupportResourceItem'
patch:
summary: Update guest policy settings
tags: [support-settings]
requestBody:
required: true
content:
application/json:
schema:
type: object
additionalProperties: true
responses:
'200':
description: Updated guest policy
content:
application/json:
schema:
$ref: '#/components/schemas/SupportResourceItem'
/support/settings/watermark:
get:
summary: Get watermark settings
tags: [support-settings]
responses:
'200':
description: Watermark settings
content:
application/json:
schema:
$ref: '#/components/schemas/SupportResourceItem'
patch:
summary: Update watermark settings
tags: [support-settings]
requestBody:
required: true
content:
application/json:
schema:
type: object
additionalProperties: true
responses:
'200':
description: Updated watermark settings
content:
application/json:
schema:
$ref: '#/components/schemas/SupportResourceItem'
/support/tenants/{tenant}/actions/activate:
post:
summary: Activate tenant
tags: [support-tenants]
parameters:
- in: path
name: tenant
required: true
schema:
type: integer
responses:
'200':
description: Activated
/support/tenants/{tenant}/actions/deactivate:
post:
summary: Deactivate tenant
tags: [support-tenants]
parameters:
- in: path
name: tenant
required: true
schema:
type: integer
responses:
'200':
description: Deactivated
/support/tenants/{tenant}/actions/suspend:
post:
summary: Suspend tenant
tags: [support-tenants]
parameters:
- in: path
name: tenant
required: true
schema:
type: integer
responses:
'200':
description: Suspended
/support/tenants/{tenant}/actions/unsuspend:
post:
summary: Unsuspend tenant
tags: [support-tenants]
parameters:
- in: path
name: tenant
required: true
schema:
type: integer
responses:
'200':
description: Unsuspended
/support/tenants/{tenant}/actions/schedule-deletion:
post:
summary: Schedule tenant deletion
tags: [support-tenants]
parameters:
- in: path
name: tenant
required: true
schema:
type: integer
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
pending_deletion_at:
type: string
format: date-time
send_warning:
type: boolean
required: [pending_deletion_at]
responses:
'200':
description: Scheduled
/support/tenants/{tenant}/actions/cancel-deletion:
post:
summary: Cancel tenant deletion
tags: [support-tenants]
parameters:
- in: path
name: tenant
required: true
schema:
type: integer
responses:
'200':
description: Cancelled
/support/tenants/{tenant}/actions/anonymize:
post:
summary: Anonymize tenant
tags: [support-tenants]
parameters:
- in: path
name: tenant
required: true
schema:
type: integer
responses:
'200':
description: Anonymize requested
/support/tenants/{tenant}/actions/add-package:
post:
summary: Add package to tenant
tags: [support-tenants]
parameters:
- in: path
name: tenant
required: true
schema:
type: integer
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
package_id:
type: integer
expires_at:
type: string
format: date-time
reason:
type: string
required: [package_id]
responses:
'200':
description: Package added
/support/tenants/{tenant}/actions/update-limits:
post:
summary: Update tenant limits
tags: [support-tenants]
parameters:
- in: path
name: tenant
required: true
schema:
type: integer
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
max_photos_per_event:
type: integer
max_storage_mb:
type: integer
note:
type: string
required: [max_photos_per_event, max_storage_mb]
responses:
'200':
description: Limits updated
/support/tenants/{tenant}/actions/update-subscription-expires-at:
post:
summary: Update tenant subscription expiry
tags: [support-tenants]
parameters:
- in: path
name: tenant
required: true
schema:
type: integer
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
subscription_expires_at:
type: string
format: date-time
note:
type: string
responses:
'200':
description: Subscription expiry updated
/support/tenants/{tenant}/actions/set-grace-period:
post:
summary: Set tenant grace period
tags: [support-tenants]
parameters:
- in: path
name: tenant
required: true
schema:
type: integer
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
grace_period_ends_at:
type: string
format: date-time
note:
type: string
required: [grace_period_ends_at]
responses:
'200':
description: Grace period set
/support/tenants/{tenant}/actions/clear-grace-period:
post:
summary: Clear tenant grace period
tags: [support-tenants]
parameters:
- in: path
name: tenant
required: true
schema:
type: integer
responses:
'200':
description: Grace period cleared
/support/{resource}:
get:
summary: List support resource
tags: [support-resources]
parameters:
- in: path
name: resource
required: true
schema:
type: string
enum:
- tenants
- users
- events
- event-types
- photos
- event-purchases
- purchases
- purchase-histories
- packages
- package-addons
- tenant-packages
- coupons
- gift-vouchers
- tenant-feedback
- tenant-announcements
- media-storage-targets
- retention-overrides
- data-exports
- photobooth-settings
- legal-pages
- blog-categories
- blog-posts
- emotions
- tasks
- task-collections
- super-admin-action-logs
- infrastructure-action-logs
- in: query
name: search
schema:
type: string
- in: query
name: sort
schema:
type: string
- in: query
name: per_page
schema:
type: integer
responses:
'200':
description: Resource list
content:
application/json:
schema:
$ref: '#/components/schemas/SupportResourceList'
post:
summary: Create support resource
tags: [support-resources]
parameters:
- in: path
name: resource
required: true
schema:
type: string
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/SupportResourcePayload'
responses:
'201':
description: Resource created
content:
application/json:
schema:
$ref: '#/components/schemas/SupportResourceItem'
/support/{resource}/{record}:
get:
summary: Get support resource
tags: [support-resources]
parameters:
- in: path
name: resource
required: true
schema:
type: string
- in: path
name: record
required: true
schema:
type: string
responses:
'200':
description: Resource
content:
application/json:
schema:
$ref: '#/components/schemas/SupportResourceItem'
patch:
summary: Update support resource
tags: [support-resources]
parameters:
- in: path
name: resource
required: true
schema:
type: string
- in: path
name: record
required: true
schema:
type: string
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/SupportResourcePayload'
responses:
'200':
description: Resource updated
content:
application/json:
schema:
$ref: '#/components/schemas/SupportResourceItem'
delete:
summary: Delete support resource
tags: [support-resources]
parameters:
- in: path
name: resource
required: true
schema:
type: string
- in: path
name: record
required: true
schema:
type: string
responses:
'200':
description: Resource deleted
content:
application/json:
schema:
type: object
properties:
ok:
type: boolean

View File

@@ -8,6 +8,11 @@ use App\Http\Controllers\Api\Marketing\CouponPreviewController;
use App\Http\Controllers\Api\PackageController;
use App\Http\Controllers\Api\PhotoboothConnectController;
use App\Http\Controllers\Api\SparkboothUploadController;
use App\Http\Controllers\Api\Support\SupportGuestPolicyController;
use App\Http\Controllers\Api\Support\SupportResourceController;
use App\Http\Controllers\Api\Support\SupportTenantActionsController;
use App\Http\Controllers\Api\Support\SupportTokenController;
use App\Http\Controllers\Api\Support\SupportWatermarkSettingsController;
use App\Http\Controllers\Api\Tenant\AdminPushSubscriptionController;
use App\Http\Controllers\Api\Tenant\DashboardController;
use App\Http\Controllers\Api\Tenant\DataExportController;
@@ -39,6 +44,7 @@ use App\Http\Controllers\Api\TenantAuth\TenantAdminPasswordResetController;
use App\Http\Controllers\Api\TenantBillingController;
use App\Http\Controllers\Api\TenantPackageController;
use App\Http\Controllers\RevenueCatWebhookController;
use App\Support\SupportApiRegistry;
use Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse;
use Illuminate\Cookie\Middleware\EncryptCookies;
use Illuminate\Session\Middleware\StartSession;
@@ -67,6 +73,71 @@ Route::prefix('v1')->name('api.v1.')->group(function () {
->middleware('throttle:60,1')
->name('webhooks.revenuecat');
Route::prefix('support')->name('support.')->group(function () {
Route::post('/auth/token', [SupportTokenController::class, 'store'])
->middleware('throttle:60,1')
->name('support.auth.token');
Route::middleware(['auth:sanctum', 'support.token'])->group(function () {
Route::post('/auth/logout', [SupportTokenController::class, 'destroy'])->name('support.auth.logout');
Route::get('/auth/me', [SupportTokenController::class, 'me'])->name('support.auth.me');
Route::get('/settings/guest-policy', [SupportGuestPolicyController::class, 'show'])
->name('support.settings.guest-policy.show');
Route::patch('/settings/guest-policy', [SupportGuestPolicyController::class, 'update'])
->name('support.settings.guest-policy.update');
Route::get('/settings/watermark', [SupportWatermarkSettingsController::class, 'show'])
->name('support.settings.watermark.show');
Route::patch('/settings/watermark', [SupportWatermarkSettingsController::class, 'update'])
->name('support.settings.watermark.update');
Route::prefix('tenants/{tenant}')->group(function () {
Route::post('actions/activate', [SupportTenantActionsController::class, 'activate'])
->name('support.tenants.actions.activate');
Route::post('actions/deactivate', [SupportTenantActionsController::class, 'deactivate'])
->name('support.tenants.actions.deactivate');
Route::post('actions/suspend', [SupportTenantActionsController::class, 'suspend'])
->name('support.tenants.actions.suspend');
Route::post('actions/unsuspend', [SupportTenantActionsController::class, 'unsuspend'])
->name('support.tenants.actions.unsuspend');
Route::post('actions/schedule-deletion', [SupportTenantActionsController::class, 'scheduleDeletion'])
->name('support.tenants.actions.schedule-deletion');
Route::post('actions/cancel-deletion', [SupportTenantActionsController::class, 'cancelDeletion'])
->name('support.tenants.actions.cancel-deletion');
Route::post('actions/anonymize', [SupportTenantActionsController::class, 'anonymize'])
->name('support.tenants.actions.anonymize');
Route::post('actions/add-package', [SupportTenantActionsController::class, 'addPackage'])
->name('support.tenants.actions.add-package');
Route::post('actions/update-limits', [SupportTenantActionsController::class, 'updateLimits'])
->name('support.tenants.actions.update-limits');
Route::post('actions/update-subscription-expires-at', [SupportTenantActionsController::class, 'updateSubscriptionExpiresAt'])
->name('support.tenants.actions.update-subscription-expires-at');
Route::post('actions/set-grace-period', [SupportTenantActionsController::class, 'setGracePeriod'])
->name('support.tenants.actions.set-grace-period');
Route::post('actions/clear-grace-period', [SupportTenantActionsController::class, 'clearGracePeriod'])
->name('support.tenants.actions.clear-grace-period');
});
$resourcePattern = SupportApiRegistry::resourcePattern();
Route::get('{resource}', [SupportResourceController::class, 'index'])
->where('resource', $resourcePattern)
->name('support.resources.index');
Route::post('{resource}', [SupportResourceController::class, 'store'])
->where('resource', $resourcePattern)
->name('support.resources.store');
Route::get('{resource}/{record}', [SupportResourceController::class, 'show'])
->where('resource', $resourcePattern)
->name('support.resources.show');
Route::patch('{resource}/{record}', [SupportResourceController::class, 'update'])
->where('resource', $resourcePattern)
->name('support.resources.update');
Route::delete('{resource}/{record}', [SupportResourceController::class, 'destroy'])
->where('resource', $resourcePattern)
->name('support.resources.destroy');
});
});
Route::prefix('tenant-auth')->name('tenant-auth.')->group(function () {
Route::post('/login', [TenantAdminTokenController::class, 'store'])
->middleware('throttle:tenant-auth')

View File

@@ -0,0 +1,37 @@
<?php
namespace Tests\Feature\Support;
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Laravel\Sanctum\Sanctum;
use Tests\TestCase;
class SupportApiTest extends TestCase
{
use RefreshDatabase;
public function test_support_resources_require_authentication(): void
{
$response = $this->getJson('/api/v1/support/tenants');
$response->assertStatus(401);
}
public function test_support_resources_allow_super_admin_tokens(): void
{
$user = User::factory()->create([
'role' => 'super_admin',
]);
Tenant::factory()->create();
Sanctum::actingAs($user, ['support-admin', 'support:read']);
$response = $this->getJson('/api/v1/support/tenants');
$response->assertOk()
->assertJsonStructure(['data', 'meta']);
}
}