diff --git a/.beads/last-touched b/.beads/last-touched index 3f348c3..a5880db 100644 --- a/.beads/last-touched +++ b/.beads/last-touched @@ -1 +1 @@ -fotospiel-app-jrij +fotospiel-app-v5dd diff --git a/app/Http/Controllers/Api/Support/SupportGuestPolicyController.php b/app/Http/Controllers/Api/Support/SupportGuestPolicyController.php new file mode 100644 index 0000000..4eda6ca --- /dev/null +++ b/app/Http/Controllers/Api/Support/SupportGuestPolicyController.php @@ -0,0 +1,53 @@ +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(), + ]); + } +} diff --git a/app/Http/Controllers/Api/Support/SupportResourceController.php b/app/Http/Controllers/Api/Support/SupportResourceController.php new file mode 100644 index 0000000..46b9c3f --- /dev/null +++ b/app/Http/Controllers/Api/Support/SupportResourceController.php @@ -0,0 +1,308 @@ +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 + ); + } +} diff --git a/app/Http/Controllers/Api/Support/SupportTenantActionsController.php b/app/Http/Controllers/Api/Support/SupportTenantActionsController.php new file mode 100644 index 0000000..4912435 --- /dev/null +++ b/app/Http/Controllers/Api/Support/SupportTenantActionsController.php @@ -0,0 +1,411 @@ +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); + } +} diff --git a/app/Http/Controllers/Api/Support/SupportTokenController.php b/app/Http/Controllers/Api/Support/SupportTokenController.php new file mode 100644 index 0000000..1dbdeef --- /dev/null +++ b/app/Http/Controllers/Api/Support/SupportTokenController.php @@ -0,0 +1,103 @@ +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 ?? [], + ]); + } +} diff --git a/app/Http/Controllers/Api/Support/SupportWatermarkSettingsController.php b/app/Http/Controllers/Api/Support/SupportWatermarkSettingsController.php new file mode 100644 index 0000000..5fb7721 --- /dev/null +++ b/app/Http/Controllers/Api/Support/SupportWatermarkSettingsController.php @@ -0,0 +1,52 @@ +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(), + ]); + } +} diff --git a/app/Http/Middleware/EnsureSupportToken.php b/app/Http/Middleware/EnsureSupportToken.php new file mode 100644 index 0000000..9ff0a64 --- /dev/null +++ b/app/Http/Middleware/EnsureSupportToken.php @@ -0,0 +1,66 @@ +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 + ); + } +} diff --git a/app/Http/Requests/Support/SupportGuestPolicyRequest.php b/app/Http/Requests/Support/SupportGuestPolicyRequest.php new file mode 100644 index 0000000..4a903bc --- /dev/null +++ b/app/Http/Requests/Support/SupportGuestPolicyRequest.php @@ -0,0 +1,36 @@ +|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'], + ]; + } +} diff --git a/app/Http/Requests/Support/SupportResourceRequest.php b/app/Http/Requests/Support/SupportResourceRequest.php new file mode 100644 index 0000000..a8d2c93 --- /dev/null +++ b/app/Http/Requests/Support/SupportResourceRequest.php @@ -0,0 +1,24 @@ +|string> + */ + public function rules(): array + { + return [ + 'data' => ['required', 'array'], + ]; + } +} diff --git a/app/Http/Requests/Support/SupportTokenRequest.php b/app/Http/Requests/Support/SupportTokenRequest.php new file mode 100644 index 0000000..e68aee5 --- /dev/null +++ b/app/Http/Requests/Support/SupportTokenRequest.php @@ -0,0 +1,52 @@ +|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} + */ + 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; + } +} diff --git a/app/Http/Requests/Support/SupportWatermarkSettingsRequest.php b/app/Http/Requests/Support/SupportWatermarkSettingsRequest.php new file mode 100644 index 0000000..4fb7921 --- /dev/null +++ b/app/Http/Requests/Support/SupportWatermarkSettingsRequest.php @@ -0,0 +1,30 @@ +|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'], + ]; + } +} diff --git a/app/Http/Requests/Support/Tenant/SupportTenantAddPackageRequest.php b/app/Http/Requests/Support/Tenant/SupportTenantAddPackageRequest.php new file mode 100644 index 0000000..0efd2fa --- /dev/null +++ b/app/Http/Requests/Support/Tenant/SupportTenantAddPackageRequest.php @@ -0,0 +1,26 @@ +|string> + */ + public function rules(): array + { + return [ + 'package_id' => ['required', 'integer'], + 'expires_at' => ['nullable', 'date'], + 'reason' => ['nullable', 'string'], + ]; + } +} diff --git a/app/Http/Requests/Support/Tenant/SupportTenantScheduleDeletionRequest.php b/app/Http/Requests/Support/Tenant/SupportTenantScheduleDeletionRequest.php new file mode 100644 index 0000000..e2cb2c5 --- /dev/null +++ b/app/Http/Requests/Support/Tenant/SupportTenantScheduleDeletionRequest.php @@ -0,0 +1,25 @@ +|string> + */ + public function rules(): array + { + return [ + 'pending_deletion_at' => ['required', 'date', 'after:now'], + 'send_warning' => ['sometimes', 'boolean'], + ]; + } +} diff --git a/app/Http/Requests/Support/Tenant/SupportTenantSetGracePeriodRequest.php b/app/Http/Requests/Support/Tenant/SupportTenantSetGracePeriodRequest.php new file mode 100644 index 0000000..884c4a2 --- /dev/null +++ b/app/Http/Requests/Support/Tenant/SupportTenantSetGracePeriodRequest.php @@ -0,0 +1,25 @@ +|string> + */ + public function rules(): array + { + return [ + 'grace_period_ends_at' => ['required', 'date', 'after_or_equal:now'], + 'note' => ['nullable', 'string'], + ]; + } +} diff --git a/app/Http/Requests/Support/Tenant/SupportTenantUpdateLimitsRequest.php b/app/Http/Requests/Support/Tenant/SupportTenantUpdateLimitsRequest.php new file mode 100644 index 0000000..35432cb --- /dev/null +++ b/app/Http/Requests/Support/Tenant/SupportTenantUpdateLimitsRequest.php @@ -0,0 +1,26 @@ +|string> + */ + public function rules(): array + { + return [ + 'max_photos_per_event' => ['required', 'integer', 'min:0'], + 'max_storage_mb' => ['required', 'integer', 'min:0'], + 'note' => ['nullable', 'string'], + ]; + } +} diff --git a/app/Http/Requests/Support/Tenant/SupportTenantUpdateSubscriptionRequest.php b/app/Http/Requests/Support/Tenant/SupportTenantUpdateSubscriptionRequest.php new file mode 100644 index 0000000..9e02bb6 --- /dev/null +++ b/app/Http/Requests/Support/Tenant/SupportTenantUpdateSubscriptionRequest.php @@ -0,0 +1,25 @@ +|string> + */ + public function rules(): array + { + return [ + 'subscription_expires_at' => ['nullable', 'date'], + 'note' => ['nullable', 'string'], + ]; + } +} diff --git a/app/Support/SupportApiAuthorizer.php b/app/Support/SupportApiAuthorizer.php new file mode 100644 index 0000000..73e98d6 --- /dev/null +++ b/app/Support/SupportApiAuthorizer.php @@ -0,0 +1,51 @@ + $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; + } +} diff --git a/app/Support/SupportApiRegistry.php b/app/Support/SupportApiRegistry.php new file mode 100644 index 0000000..fdaa71a --- /dev/null +++ b/app/Support/SupportApiRegistry.php @@ -0,0 +1,118 @@ +> + */ + public static function resources(): array + { + return config('support-api.resources', []); + } + + /** + * @return array|null + */ + public static function get(string $resource): ?array + { + $resources = self::resources(); + + return $resources[$resource] ?? null; + } + + /** + * @return array + */ + 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 + */ + 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 + */ + public static function searchFields(string $resource): array + { + $config = self::get($resource); + + $fields = $config['search'] ?? []; + + return is_array($fields) ? $fields : []; + } + + /** + * @return array + */ + public static function withRelations(string $resource): array + { + $config = self::get($resource); + + $relations = $config['with'] ?? []; + + return is_array($relations) ? $relations : []; + } +} diff --git a/bootstrap/app.php b/bootstrap/app.php index 441a578..1359db5 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -1,6 +1,7 @@ CreditCheckMiddleware::class, 'tenant.admin' => EnsureTenantAdminToken::class, 'tenant.collaborator' => EnsureTenantCollaboratorToken::class, + 'support.token' => EnsureSupportToken::class, ]); $middleware->encryptCookies(except: ['appearance', 'sidebar_state']); diff --git a/config/support-api.php b/config/support-api.php new file mode 100644 index 0000000..fc8ade4 --- /dev/null +++ b/config/support-api.php @@ -0,0 +1,295 @@ + [ + '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'], + ], + ], + ], +]; diff --git a/docs/openapi/support-api.yaml b/docs/openapi/support-api.yaml new file mode 100644 index 0000000..4c4ac52 --- /dev/null +++ b/docs/openapi/support-api.yaml @@ -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 diff --git a/routes/api.php b/routes/api.php index 5941dc4..72adb72 100644 --- a/routes/api.php +++ b/routes/api.php @@ -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') diff --git a/tests/Feature/Support/SupportApiTest.php b/tests/Feature/Support/SupportApiTest.php new file mode 100644 index 0000000..79886ea --- /dev/null +++ b/tests/Feature/Support/SupportApiTest.php @@ -0,0 +1,37 @@ +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']); + } +}