Add support API scaffold
This commit is contained in:
@@ -1 +1 @@
|
||||
fotospiel-app-jrij
|
||||
fotospiel-app-v5dd
|
||||
|
||||
@@ -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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
308
app/Http/Controllers/Api/Support/SupportResourceController.php
Normal file
308
app/Http/Controllers/Api/Support/SupportResourceController.php
Normal 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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
103
app/Http/Controllers/Api/Support/SupportTokenController.php
Normal file
103
app/Http/Controllers/Api/Support/SupportTokenController.php
Normal 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 ?? [],
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
66
app/Http/Middleware/EnsureSupportToken.php
Normal file
66
app/Http/Middleware/EnsureSupportToken.php
Normal 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
|
||||
);
|
||||
}
|
||||
}
|
||||
36
app/Http/Requests/Support/SupportGuestPolicyRequest.php
Normal file
36
app/Http/Requests/Support/SupportGuestPolicyRequest.php
Normal 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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
24
app/Http/Requests/Support/SupportResourceRequest.php
Normal file
24
app/Http/Requests/Support/SupportResourceRequest.php
Normal 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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
52
app/Http/Requests/Support/SupportTokenRequest.php
Normal file
52
app/Http/Requests/Support/SupportTokenRequest.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
51
app/Support/SupportApiAuthorizer.php
Normal file
51
app/Support/SupportApiAuthorizer.php
Normal 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;
|
||||
}
|
||||
}
|
||||
118
app/Support/SupportApiRegistry.php
Normal file
118
app/Support/SupportApiRegistry.php
Normal 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 : [];
|
||||
}
|
||||
}
|
||||
@@ -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
295
config/support-api.php
Normal 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'],
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
544
docs/openapi/support-api.yaml
Normal file
544
docs/openapi/support-api.yaml
Normal 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
|
||||
@@ -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')
|
||||
|
||||
37
tests/Feature/Support/SupportApiTest.php
Normal file
37
tests/Feature/Support/SupportApiTest.php
Normal 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']);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user