Limit-Status im Upload-Flow anzeigen (Warnbanner + Sperrzustände).
Upload-Fehlercodes auswerten und freundliche Dialoge zeigen.
This commit is contained in:
@@ -150,12 +150,16 @@ class EventPublicController extends BaseController
|
||||
Response::HTTP_FORBIDDEN
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'error' => [
|
||||
'code' => 'event_not_public',
|
||||
'message' => 'This event is not publicly accessible.',
|
||||
],
|
||||
], Response::HTTP_FORBIDDEN);
|
||||
return ApiError::response(
|
||||
'event_not_public',
|
||||
'Event Not Public',
|
||||
'This event is not publicly accessible.',
|
||||
Response::HTTP_FORBIDDEN,
|
||||
[
|
||||
'token' => Str::limit($token, 12),
|
||||
'event_id' => $event->id ?? null,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
RateLimiter::clear($rateLimiterKey);
|
||||
@@ -199,12 +203,15 @@ class EventPublicController extends BaseController
|
||||
$event = Event::with(['tenant', 'eventPackage.package'])->find($eventRecord->id);
|
||||
|
||||
if (! $event) {
|
||||
return response()->json([
|
||||
'error' => [
|
||||
'code' => 'event_not_found',
|
||||
'message' => 'The event associated with this gallery could not be located.',
|
||||
],
|
||||
], Response::HTTP_NOT_FOUND);
|
||||
return ApiError::response(
|
||||
'event_not_found',
|
||||
'Event Not Found',
|
||||
'The event associated with this gallery could not be located.',
|
||||
Response::HTTP_NOT_FOUND,
|
||||
[
|
||||
'token' => Str::limit($token, 12),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
$expiresAt = optional($event->eventPackage)->gallery_expires_at;
|
||||
@@ -222,13 +229,16 @@ class EventPublicController extends BaseController
|
||||
Response::HTTP_GONE
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'error' => [
|
||||
'code' => 'gallery_expired',
|
||||
'message' => 'The gallery is no longer available for this event.',
|
||||
return ApiError::response(
|
||||
'gallery_expired',
|
||||
'Gallery Expired',
|
||||
'The gallery is no longer available for this event.',
|
||||
Response::HTTP_GONE,
|
||||
[
|
||||
'event_id' => $event->id,
|
||||
'expired_at' => $expiresAt->toIso8601String(),
|
||||
],
|
||||
], Response::HTTP_GONE);
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
$this->recordTokenEvent(
|
||||
@@ -271,12 +281,13 @@ class EventPublicController extends BaseController
|
||||
Response::HTTP_TOO_MANY_REQUESTS
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'error' => [
|
||||
'code' => 'token_rate_limited',
|
||||
'message' => 'Too many invalid join token attempts. Try again later.',
|
||||
],
|
||||
], Response::HTTP_TOO_MANY_REQUESTS);
|
||||
return ApiError::response(
|
||||
'token_rate_limited',
|
||||
'Too Many Attempts',
|
||||
'Too many invalid join token attempts. Try again later.',
|
||||
Response::HTTP_TOO_MANY_REQUESTS,
|
||||
array_merge($context, ['rate_limiter_key' => $rateLimiterKey])
|
||||
);
|
||||
}
|
||||
|
||||
RateLimiter::hit($rateLimiterKey, $failureDecay * 60);
|
||||
@@ -295,12 +306,13 @@ class EventPublicController extends BaseController
|
||||
$status
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'error' => [
|
||||
'code' => $code,
|
||||
'message' => $this->tokenErrorMessage($code),
|
||||
],
|
||||
], $status);
|
||||
return ApiError::response(
|
||||
$code,
|
||||
$this->tokenErrorTitle($code),
|
||||
$this->tokenErrorMessage($code),
|
||||
$status,
|
||||
$context
|
||||
);
|
||||
}
|
||||
|
||||
private function tokenErrorMessage(string $code): string
|
||||
@@ -313,6 +325,17 @@ class EventPublicController extends BaseController
|
||||
};
|
||||
}
|
||||
|
||||
private function tokenErrorTitle(string $code): string
|
||||
{
|
||||
return match ($code) {
|
||||
'invalid_token' => 'Invalid Join Token',
|
||||
'token_expired' => 'Join Token Expired',
|
||||
'token_revoked' => 'Join Token Revoked',
|
||||
'token_rate_limited' => 'Join Token Rate Limited',
|
||||
default => 'Access Denied',
|
||||
};
|
||||
}
|
||||
|
||||
private function recordTokenEvent(
|
||||
?EventJoinToken $joinToken,
|
||||
Request $request,
|
||||
@@ -347,12 +370,16 @@ class EventPublicController extends BaseController
|
||||
Response::HTTP_TOO_MANY_REQUESTS
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'error' => [
|
||||
'code' => 'access_rate_limited',
|
||||
'message' => 'Too many requests. Please slow down.',
|
||||
],
|
||||
], Response::HTTP_TOO_MANY_REQUESTS);
|
||||
return ApiError::response(
|
||||
'access_rate_limited',
|
||||
'Too Many Requests',
|
||||
'Too many requests. Please slow down.',
|
||||
Response::HTTP_TOO_MANY_REQUESTS,
|
||||
[
|
||||
'limit' => $limit,
|
||||
'decay_minutes' => $decay,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
RateLimiter::hit($key, $decay * 60);
|
||||
@@ -383,12 +410,16 @@ class EventPublicController extends BaseController
|
||||
Response::HTTP_TOO_MANY_REQUESTS
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'error' => [
|
||||
'code' => 'download_rate_limited',
|
||||
'message' => 'Download rate limit exceeded. Please wait a moment.',
|
||||
],
|
||||
], Response::HTTP_TOO_MANY_REQUESTS);
|
||||
return ApiError::response(
|
||||
'download_rate_limited',
|
||||
'Download Rate Limited',
|
||||
'Download rate limit exceeded. Please wait a moment.',
|
||||
Response::HTTP_TOO_MANY_REQUESTS,
|
||||
[
|
||||
'limit' => $limit,
|
||||
'decay_minutes' => $decay,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
RateLimiter::hit($key, $decay * 60);
|
||||
@@ -664,12 +695,16 @@ class EventPublicController extends BaseController
|
||||
->first();
|
||||
|
||||
if (! $record) {
|
||||
return response()->json([
|
||||
'error' => [
|
||||
'code' => 'photo_not_found',
|
||||
'message' => 'The requested photo is no longer available.',
|
||||
],
|
||||
], Response::HTTP_NOT_FOUND);
|
||||
return ApiError::response(
|
||||
'photo_not_found',
|
||||
'Photo Not Found',
|
||||
'The requested photo is no longer available.',
|
||||
Response::HTTP_NOT_FOUND,
|
||||
[
|
||||
'photo_id' => $photo,
|
||||
'event_id' => $event->id,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
$variantPreference = $variant === 'thumbnail'
|
||||
@@ -697,12 +732,16 @@ class EventPublicController extends BaseController
|
||||
->first();
|
||||
|
||||
if (! $record) {
|
||||
return response()->json([
|
||||
'error' => [
|
||||
'code' => 'photo_not_found',
|
||||
'message' => 'The requested photo is no longer available.',
|
||||
],
|
||||
], Response::HTTP_NOT_FOUND);
|
||||
return ApiError::response(
|
||||
'photo_not_found',
|
||||
'Photo Not Found',
|
||||
'The requested photo is no longer available.',
|
||||
Response::HTTP_NOT_FOUND,
|
||||
[
|
||||
'photo_id' => $photo,
|
||||
'event_id' => $event->id,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
return $this->streamGalleryPhoto($event, $record, ['original'], 'attachment');
|
||||
@@ -763,6 +802,69 @@ class EventPublicController extends BaseController
|
||||
])->header('Cache-Control', 'no-store');
|
||||
}
|
||||
|
||||
public function package(Request $request, string $token)
|
||||
{
|
||||
$result = $this->resolvePublishedEvent($request, $token, ['id']);
|
||||
|
||||
if ($result instanceof JsonResponse) {
|
||||
return $result;
|
||||
}
|
||||
|
||||
[$eventRecord, $joinToken] = $result;
|
||||
|
||||
$event = Event::with(['tenant', 'eventPackage.package', 'eventPackages.package'])
|
||||
->findOrFail($eventRecord->id);
|
||||
|
||||
if (! $event->tenant) {
|
||||
return ApiError::response(
|
||||
'event_not_found',
|
||||
'Event not accessible',
|
||||
'The selected event is no longer available.',
|
||||
Response::HTTP_NOT_FOUND,
|
||||
['scope' => 'photos', 'event_id' => $event->id]
|
||||
);
|
||||
}
|
||||
|
||||
$eventPackage = $this->packageLimitEvaluator->resolveEventPackageForPhotoUpload(
|
||||
$event->tenant,
|
||||
$event->id,
|
||||
$event
|
||||
);
|
||||
|
||||
if (! $eventPackage || ! $eventPackage->package) {
|
||||
return response()->json([
|
||||
'id' => null,
|
||||
'event_id' => $event->id,
|
||||
'package_id' => null,
|
||||
'package' => null,
|
||||
'used_photos' => (int) ($eventPackage?->used_photos ?? 0),
|
||||
'used_guests' => (int) ($eventPackage?->used_guests ?? 0),
|
||||
'expires_at' => $eventPackage?->gallery_expires_at?->toIso8601String(),
|
||||
'limits' => null,
|
||||
])->header('Cache-Control', 'no-store');
|
||||
}
|
||||
|
||||
$package = $eventPackage->package;
|
||||
$summary = $this->packageLimitEvaluator->summarizeEventPackage($eventPackage);
|
||||
|
||||
return response()->json([
|
||||
'id' => $eventPackage->id,
|
||||
'event_id' => $event->id,
|
||||
'package_id' => $eventPackage->package_id,
|
||||
'package' => [
|
||||
'id' => $eventPackage->package_id,
|
||||
'name' => $package?->getNameForLocale(app()->getLocale()) ?? $package?->name,
|
||||
'max_photos' => $package?->max_photos,
|
||||
'max_guests' => $package?->max_guests,
|
||||
'gallery_days' => $package?->gallery_days,
|
||||
],
|
||||
'used_photos' => (int) $eventPackage->used_photos,
|
||||
'used_guests' => (int) $eventPackage->used_guests,
|
||||
'expires_at' => $eventPackage->gallery_expires_at?->toIso8601String(),
|
||||
'limits' => $summary,
|
||||
])->header('Cache-Control', 'no-store');
|
||||
}
|
||||
|
||||
private function streamGalleryPhoto(Event $event, Photo $record, array $variantPreference, string $disposition)
|
||||
{
|
||||
foreach ($variantPreference as $variant) {
|
||||
@@ -852,12 +954,16 @@ class EventPublicController extends BaseController
|
||||
return redirect()->away($fallbackUrl);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'error' => [
|
||||
'code' => 'photo_unavailable',
|
||||
'message' => 'The requested photo could not be loaded.',
|
||||
],
|
||||
], Response::HTTP_NOT_FOUND);
|
||||
return ApiError::response(
|
||||
'photo_unavailable',
|
||||
'Photo Unavailable',
|
||||
'The requested photo could not be loaded.',
|
||||
Response::HTTP_NOT_FOUND,
|
||||
[
|
||||
'photo_id' => $record->id,
|
||||
'event_id' => $event->id,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
private function resolvePhotoVariant(Photo $record, string $variant): array
|
||||
@@ -1191,7 +1297,13 @@ class EventPublicController extends BaseController
|
||||
->where('events.status', 'published')
|
||||
->first();
|
||||
if (! $row) {
|
||||
return response()->json(['error' => ['code' => 'not_found', 'message' => 'Photo not found or event not public']], 404);
|
||||
return ApiError::response(
|
||||
'photo_not_found',
|
||||
'Photo Not Found',
|
||||
'Photo not found or event not public.',
|
||||
Response::HTTP_NOT_FOUND,
|
||||
['photo_id' => $id]
|
||||
);
|
||||
}
|
||||
$row->file_path = $this->toPublicUrl((string) ($row->file_path ?? ''));
|
||||
$row->thumbnail_path = $this->toPublicUrl((string) ($row->thumbnail_path ?? ''));
|
||||
@@ -1219,7 +1331,13 @@ class EventPublicController extends BaseController
|
||||
->where('events.status', 'published')
|
||||
->first(['photos.id', 'photos.event_id']);
|
||||
if (! $photo) {
|
||||
return response()->json(['error' => ['code' => 'not_found', 'message' => 'Photo not found or event not public']], 404);
|
||||
return ApiError::response(
|
||||
'photo_not_found',
|
||||
'Photo Not Found',
|
||||
'Photo not found or event not public.',
|
||||
Response::HTTP_NOT_FOUND,
|
||||
['photo_id' => $id]
|
||||
);
|
||||
}
|
||||
|
||||
// Idempotent like per device
|
||||
|
||||
@@ -3,8 +3,10 @@
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Models\LegalPage;
|
||||
use App\Support\ApiError;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Routing\Controller as BaseController;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class LegalController extends BaseController
|
||||
{
|
||||
@@ -27,7 +29,13 @@ class LegalController extends BaseController
|
||||
->orderByDesc('version')
|
||||
->first();
|
||||
if (! $page) {
|
||||
return response()->json(['error' => ['code' => 'not_found', 'message' => 'Legal page not found']], 404);
|
||||
return ApiError::response(
|
||||
'legal_page_not_found',
|
||||
'Legal Page Not Found',
|
||||
'The requested legal document does not exist.',
|
||||
Response::HTTP_NOT_FOUND,
|
||||
['slug' => $resolved]
|
||||
);
|
||||
}
|
||||
|
||||
$title = $page->title[$locale] ?? $page->title[$page->locale_fallback] ?? $page->title['de'] ?? $page->title['en'] ?? $page->slug;
|
||||
|
||||
@@ -16,9 +16,7 @@ use Stripe\Webhook;
|
||||
|
||||
class StripeWebhookController extends Controller
|
||||
{
|
||||
public function __construct(private CheckoutWebhookService $checkoutWebhooks)
|
||||
{
|
||||
}
|
||||
public function __construct(private CheckoutWebhookService $checkoutWebhooks) {}
|
||||
|
||||
public function handleWebhook(Request $request)
|
||||
{
|
||||
@@ -33,9 +31,19 @@ class StripeWebhookController extends Controller
|
||||
$endpointSecret
|
||||
);
|
||||
} catch (SignatureVerificationException $e) {
|
||||
return response()->json(['error' => 'Invalid signature'], 400);
|
||||
return ApiError::response(
|
||||
'stripe_invalid_signature',
|
||||
'Ungültige Signatur',
|
||||
'Die Signatur der Stripe-Anfrage ist ungültig.',
|
||||
400
|
||||
);
|
||||
} catch (\UnexpectedValueException $e) {
|
||||
return response()->json(['error' => 'Invalid payload'], 400);
|
||||
return ApiError::response(
|
||||
'stripe_invalid_payload',
|
||||
'Ungültige Daten',
|
||||
'Der Stripe Payload konnte nicht gelesen werden.',
|
||||
400
|
||||
);
|
||||
}
|
||||
|
||||
$eventArray = method_exists($event, 'toArray') ? $event->toArray() : (array) $event;
|
||||
@@ -78,6 +86,7 @@ class StripeWebhookController extends Controller
|
||||
|
||||
if (! $packageId || ! $type) {
|
||||
Log::warning('Stripe intent missing metadata payload', ['metadata' => $metadata]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -178,4 +187,3 @@ class StripeWebhookController extends Controller
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ use App\Models\Package;
|
||||
use App\Models\Photo;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\EventJoinTokenService;
|
||||
use App\Support\ApiError;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||
@@ -204,7 +205,13 @@ class EventController extends Controller
|
||||
$tenantId = $request->attributes->get('tenant_id');
|
||||
|
||||
if ($event->tenant_id !== $tenantId) {
|
||||
return response()->json(['error' => 'Event not found'], 404);
|
||||
return ApiError::response(
|
||||
'event_not_found',
|
||||
'Event not accessible',
|
||||
'Das Event konnte nicht gefunden werden.',
|
||||
404,
|
||||
['event_slug' => $event->slug ?? null]
|
||||
);
|
||||
}
|
||||
|
||||
$event->load([
|
||||
@@ -228,7 +235,13 @@ class EventController extends Controller
|
||||
$tenantId = $request->attributes->get('tenant_id');
|
||||
|
||||
if ($event->tenant_id !== $tenantId) {
|
||||
return response()->json(['error' => 'Event not found'], 404);
|
||||
return ApiError::response(
|
||||
'event_not_found',
|
||||
'Event not accessible',
|
||||
'Das Event konnte nicht gefunden werden.',
|
||||
404,
|
||||
['event_slug' => $event->slug ?? null]
|
||||
);
|
||||
}
|
||||
|
||||
$validated = $request->validated();
|
||||
@@ -264,7 +277,13 @@ class EventController extends Controller
|
||||
$tenantId = $request->attributes->get('tenant_id');
|
||||
|
||||
if ($event->tenant_id !== $tenantId) {
|
||||
return response()->json(['error' => 'Event not found'], 404);
|
||||
return ApiError::response(
|
||||
'event_not_found',
|
||||
'Event not accessible',
|
||||
'Das Event konnte nicht gefunden werden.',
|
||||
404,
|
||||
['event_slug' => $event->slug ?? null]
|
||||
);
|
||||
}
|
||||
|
||||
$event->delete();
|
||||
@@ -279,7 +298,13 @@ class EventController extends Controller
|
||||
$tenantId = $request->attributes->get('tenant_id');
|
||||
|
||||
if ($event->tenant_id !== $tenantId) {
|
||||
return response()->json(['error' => 'Event not found'], 404);
|
||||
return ApiError::response(
|
||||
'event_not_found',
|
||||
'Event not accessible',
|
||||
'Das Event konnte nicht gefunden werden.',
|
||||
404,
|
||||
['event_slug' => $event->slug ?? null]
|
||||
);
|
||||
}
|
||||
|
||||
$totalPhotos = Photo::where('event_id', $event->id)->count();
|
||||
@@ -304,7 +329,13 @@ class EventController extends Controller
|
||||
$tenantId = $request->attributes->get('tenant_id');
|
||||
|
||||
if ($event->tenant_id !== $tenantId) {
|
||||
return response()->json(['error' => 'Event not found'], 404);
|
||||
return ApiError::response(
|
||||
'event_not_found',
|
||||
'Event not accessible',
|
||||
'Das Event konnte nicht gefunden werden.',
|
||||
404,
|
||||
['event_slug' => $event->slug ?? null]
|
||||
);
|
||||
}
|
||||
|
||||
$event->load(['eventType', 'eventPackage.package']);
|
||||
@@ -439,7 +470,13 @@ class EventController extends Controller
|
||||
$tenantId = $request->attributes->get('tenant_id');
|
||||
|
||||
if ($event->tenant_id !== $tenantId) {
|
||||
return response()->json(['error' => 'Event not found'], 404);
|
||||
return ApiError::response(
|
||||
'event_not_found',
|
||||
'Event not accessible',
|
||||
'Das Event konnte nicht gefunden werden.',
|
||||
404,
|
||||
['event_slug' => $event->slug ?? null]
|
||||
);
|
||||
}
|
||||
|
||||
$activate = ! (bool) $event->is_active;
|
||||
@@ -466,7 +503,13 @@ class EventController extends Controller
|
||||
$tenantId = $request->attributes->get('tenant_id');
|
||||
|
||||
if ($event->tenant_id !== $tenantId) {
|
||||
return response()->json(['error' => 'Event not found'], 404);
|
||||
return ApiError::response(
|
||||
'event_not_found',
|
||||
'Event not accessible',
|
||||
'Das Event konnte nicht gefunden werden.',
|
||||
404,
|
||||
['event_slug' => $event->slug ?? null]
|
||||
);
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
|
||||
@@ -22,6 +22,7 @@ use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class PhotoController extends Controller
|
||||
{
|
||||
@@ -46,27 +47,16 @@ class PhotoController extends Controller
|
||||
->where('tenant_id', $tenantId)
|
||||
->firstOrFail();
|
||||
|
||||
$event->loadMissing(['tenant', 'eventPackage.package', 'eventPackages.package']);
|
||||
$tenant = $event->tenant;
|
||||
|
||||
if ($tenant) {
|
||||
$violation = $this->packageLimitEvaluator->assessPhotoUpload($tenant, $event->id, $event);
|
||||
|
||||
if ($violation !== null) {
|
||||
return ApiError::response(
|
||||
$violation['code'],
|
||||
$violation['title'],
|
||||
$violation['message'],
|
||||
$violation['status'],
|
||||
$violation['meta']
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
$eventPackage = $tenant
|
||||
? $this->packageLimitEvaluator->resolveEventPackageForPhotoUpload($tenant, $event->id, $event)
|
||||
: null;
|
||||
|
||||
$previousUsedPhotos = $eventPackage?->used_photos ?? 0;
|
||||
$limitSummary = $eventPackage
|
||||
? $this->packageLimitEvaluator->summarizeEventPackage($eventPackage)
|
||||
: null;
|
||||
|
||||
$query = Photo::where('event_id', $event->id)
|
||||
->with('event')->withCount('likes')
|
||||
@@ -84,7 +74,9 @@ class PhotoController extends Controller
|
||||
$perPage = $request->get('per_page', 20);
|
||||
$photos = $query->paginate($perPage);
|
||||
|
||||
return PhotoResource::collection($photos);
|
||||
return PhotoResource::collection($photos)->additional([
|
||||
'limits' => $limitSummary,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -97,6 +89,29 @@ class PhotoController extends Controller
|
||||
->where('tenant_id', $tenantId)
|
||||
->firstOrFail();
|
||||
|
||||
$event->loadMissing(['tenant', 'eventPackage.package', 'eventPackages.package']);
|
||||
$tenant = $event->tenant;
|
||||
|
||||
$eventPackage = $tenant
|
||||
? $this->packageLimitEvaluator->resolveEventPackageForPhotoUpload($tenant, $event->id, $event)
|
||||
: null;
|
||||
|
||||
if ($tenant) {
|
||||
$violation = $this->packageLimitEvaluator->assessPhotoUpload($tenant, $event->id, $event);
|
||||
|
||||
if ($violation !== null) {
|
||||
return ApiError::response(
|
||||
$violation['code'],
|
||||
$violation['title'],
|
||||
$violation['message'],
|
||||
$violation['status'],
|
||||
$violation['meta']
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
$previousUsedPhotos = $eventPackage?->used_photos ?? 0;
|
||||
|
||||
$validated = $request->validated();
|
||||
$file = $request->file('photo');
|
||||
|
||||
@@ -197,12 +212,17 @@ class PhotoController extends Controller
|
||||
$this->packageUsageTracker->recordPhotoUsage($eventPackage, $previousUsedPhotos, 1);
|
||||
}
|
||||
|
||||
$limitSummary = $eventPackage
|
||||
? $this->packageLimitEvaluator->summarizeEventPackage($eventPackage)
|
||||
: null;
|
||||
|
||||
$photo->load('event')->loadCount('likes');
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Photo uploaded successfully. Awaiting moderation.',
|
||||
'data' => new PhotoResource($photo),
|
||||
'moderation_notice' => 'Your photo has been uploaded and will be reviewed shortly.',
|
||||
'limits' => $limitSummary,
|
||||
], 201);
|
||||
}
|
||||
|
||||
@@ -217,7 +237,13 @@ class PhotoController extends Controller
|
||||
->firstOrFail();
|
||||
|
||||
if ($photo->event_id !== $event->id) {
|
||||
return response()->json(['error' => 'Photo not found'], 404);
|
||||
return ApiError::response(
|
||||
'photo_not_found',
|
||||
'Foto nicht gefunden',
|
||||
'Das Foto gehört nicht zu diesem Event.',
|
||||
404,
|
||||
['photo_id' => $photo->id]
|
||||
);
|
||||
}
|
||||
|
||||
$photo->load('event')->loadCount('likes');
|
||||
@@ -239,7 +265,13 @@ class PhotoController extends Controller
|
||||
->firstOrFail();
|
||||
|
||||
if ($photo->event_id !== $event->id) {
|
||||
return response()->json(['error' => 'Photo not found'], 404);
|
||||
return ApiError::response(
|
||||
'photo_not_found',
|
||||
'Foto nicht gefunden',
|
||||
'Das Foto gehört nicht zu diesem Event.',
|
||||
404,
|
||||
['photo_id' => $photo->id]
|
||||
);
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
@@ -251,7 +283,13 @@ class PhotoController extends Controller
|
||||
|
||||
// Only tenant admins can moderate
|
||||
if (isset($validated['status']) && ! $this->tokenHasScope($request, 'tenant:write')) {
|
||||
return response()->json(['error' => 'Insufficient scopes'], 403);
|
||||
return ApiError::response(
|
||||
'insufficient_scope',
|
||||
'Insufficient Scopes',
|
||||
'You are not allowed to moderate photos for this event.',
|
||||
Response::HTTP_FORBIDDEN,
|
||||
['required_scope' => 'tenant:write']
|
||||
);
|
||||
}
|
||||
|
||||
$photo->update($validated);
|
||||
@@ -279,7 +317,13 @@ class PhotoController extends Controller
|
||||
->firstOrFail();
|
||||
|
||||
if ($photo->event_id !== $event->id) {
|
||||
return response()->json(['error' => 'Photo not found'], 404);
|
||||
return ApiError::response(
|
||||
'photo_not_found',
|
||||
'Foto nicht gefunden',
|
||||
'Das Foto gehört nicht zu diesem Event.',
|
||||
404,
|
||||
['photo_id' => $photo->id]
|
||||
);
|
||||
}
|
||||
|
||||
$assets = EventMediaAsset::where('photo_id', $photo->id)->get();
|
||||
@@ -303,6 +347,9 @@ class PhotoController extends Controller
|
||||
Storage::disk($fallbackDisk)->delete([$photo->path, $photo->thumbnail_path]);
|
||||
}
|
||||
|
||||
$eventPackage = $event->eventPackage;
|
||||
$usageTracker = app(\App\Services\Packages\PackageUsageTracker::class);
|
||||
|
||||
// Delete record and likes
|
||||
DB::transaction(function () use ($photo, $assets) {
|
||||
$photo->likes()->delete();
|
||||
@@ -312,6 +359,15 @@ class PhotoController extends Controller
|
||||
$photo->delete();
|
||||
});
|
||||
|
||||
if ($eventPackage && $eventPackage->package) {
|
||||
$previousUsed = (int) $eventPackage->used_photos;
|
||||
if ($previousUsed > 0) {
|
||||
$eventPackage->decrement('used_photos');
|
||||
$eventPackage->refresh();
|
||||
$usageTracker->recordPhotoUsage($eventPackage, $previousUsed, -1);
|
||||
}
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Photo deleted successfully',
|
||||
]);
|
||||
@@ -328,7 +384,13 @@ class PhotoController extends Controller
|
||||
->firstOrFail();
|
||||
|
||||
if ($photo->event_id !== $event->id) {
|
||||
return response()->json(['error' => 'Photo not found'], 404);
|
||||
return ApiError::response(
|
||||
'photo_not_found',
|
||||
'Photo not found',
|
||||
'The specified photo could not be located for this event.',
|
||||
Response::HTTP_NOT_FOUND,
|
||||
['photo_id' => $photo->id, 'event_id' => $event->id]
|
||||
);
|
||||
}
|
||||
|
||||
$photo->update(['is_featured' => true]);
|
||||
@@ -345,7 +407,13 @@ class PhotoController extends Controller
|
||||
->firstOrFail();
|
||||
|
||||
if ($photo->event_id !== $event->id) {
|
||||
return response()->json(['error' => 'Photo not found'], 404);
|
||||
return ApiError::response(
|
||||
'photo_not_found',
|
||||
'Photo not found',
|
||||
'The specified photo could not be located for this event.',
|
||||
Response::HTTP_NOT_FOUND,
|
||||
['photo_id' => $photo->id, 'event_id' => $event->id]
|
||||
);
|
||||
}
|
||||
|
||||
$photo->update(['is_featured' => false]);
|
||||
@@ -569,7 +637,13 @@ class PhotoController extends Controller
|
||||
]);
|
||||
|
||||
if ($request->event_id !== $event->id) {
|
||||
return response()->json(['error' => 'Invalid event ID'], 400);
|
||||
return ApiError::response(
|
||||
'event_mismatch',
|
||||
'Invalid Event',
|
||||
'The provided event does not match the authenticated tenant event.',
|
||||
Response::HTTP_BAD_REQUEST,
|
||||
['payload_event_id' => $request->event_id, 'expected_event_id' => $event->id]
|
||||
);
|
||||
}
|
||||
|
||||
$event->load('storageAssignments.storageTarget');
|
||||
|
||||
@@ -3,10 +3,14 @@
|
||||
namespace App\Http\Controllers\Api\Tenant;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Tenant\NotificationPreferencesRequest;
|
||||
use App\Http\Requests\Tenant\SettingsStoreRequest;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Packages\TenantNotificationPreferences;
|
||||
use App\Support\ApiError;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class SettingsController extends Controller
|
||||
{
|
||||
@@ -27,6 +31,62 @@ class SettingsController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
public function notificationPreferences(
|
||||
Request $request,
|
||||
TenantNotificationPreferences $preferencesService
|
||||
): JsonResponse {
|
||||
$tenant = $request->tenant;
|
||||
$defaults = TenantNotificationPreferences::defaults();
|
||||
$resolved = [];
|
||||
|
||||
foreach (array_keys($defaults) as $key) {
|
||||
$resolved[$key] = $preferencesService->shouldNotify($tenant, $key);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'data' => [
|
||||
'defaults' => $defaults,
|
||||
'preferences' => $resolved,
|
||||
'overrides' => $tenant->notification_preferences ?? null,
|
||||
'meta' => [
|
||||
'credit_warning_sent_at' => $tenant->credit_warning_sent_at?->toIso8601String(),
|
||||
'credit_warning_threshold' => $tenant->credit_warning_threshold,
|
||||
],
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function updateNotificationPreferences(
|
||||
NotificationPreferencesRequest $request,
|
||||
TenantNotificationPreferences $preferencesService
|
||||
): JsonResponse {
|
||||
$tenant = $request->tenant;
|
||||
$payload = $request->validated()['preferences'];
|
||||
|
||||
$tenant->update([
|
||||
'notification_preferences' => $payload,
|
||||
]);
|
||||
|
||||
$tenant->refresh();
|
||||
|
||||
$resolved = [];
|
||||
foreach (array_keys(TenantNotificationPreferences::defaults()) as $key) {
|
||||
$resolved[$key] = $preferencesService->shouldNotify($tenant->fresh(), $key);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Benachrichtigungseinstellungen aktualisiert.',
|
||||
'data' => [
|
||||
'preferences' => $resolved,
|
||||
'overrides' => $tenant->notification_preferences,
|
||||
'meta' => [
|
||||
'credit_warning_sent_at' => $tenant->credit_warning_sent_at?->toIso8601String(),
|
||||
'credit_warning_threshold' => $tenant->credit_warning_threshold,
|
||||
],
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the tenant's settings.
|
||||
*/
|
||||
@@ -98,7 +158,12 @@ class SettingsController extends Controller
|
||||
$domain = $request->input('domain');
|
||||
|
||||
if (! $domain) {
|
||||
return response()->json(['error' => 'Domain ist erforderlich.'], 400);
|
||||
return ApiError::response(
|
||||
'domain_missing',
|
||||
'Domain erforderlich',
|
||||
'Bitte gib eine Domain an.',
|
||||
Response::HTTP_BAD_REQUEST
|
||||
);
|
||||
}
|
||||
|
||||
if (! $this->isValidDomain($domain)) {
|
||||
|
||||
@@ -6,20 +6,19 @@ use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Tenant\TaskStoreRequest;
|
||||
use App\Http\Requests\Tenant\TaskUpdateRequest;
|
||||
use App\Http\Resources\Tenant\TaskResource;
|
||||
use App\Models\Event;
|
||||
use App\Models\Task;
|
||||
use App\Models\TaskCollection;
|
||||
use App\Models\Event;
|
||||
use App\Support\ApiError;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class TaskController extends Controller
|
||||
{
|
||||
/**
|
||||
* Display a listing of the tenant's tasks.
|
||||
*
|
||||
* @param Request $request
|
||||
* @return AnonymousResourceCollection
|
||||
*/
|
||||
public function index(Request $request): AnonymousResourceCollection
|
||||
{
|
||||
@@ -38,7 +37,7 @@ class TaskController extends Controller
|
||||
// Search and filters
|
||||
if ($search = $request->get('search')) {
|
||||
$query->where(function ($inner) use ($search) {
|
||||
$like = '%' . $search . '%';
|
||||
$like = '%'.$search.'%';
|
||||
$inner->where('title->de', 'like', $like)
|
||||
->orWhere('title->en', 'like', $like)
|
||||
->orWhere('description->de', 'like', $like)
|
||||
@@ -47,11 +46,11 @@ class TaskController extends Controller
|
||||
}
|
||||
|
||||
if ($collectionId = $request->get('collection_id')) {
|
||||
$query->whereHas('taskCollection', fn($q) => $q->where('id', $collectionId));
|
||||
$query->whereHas('taskCollection', fn ($q) => $q->where('id', $collectionId));
|
||||
}
|
||||
|
||||
if ($eventId = $request->get('event_id')) {
|
||||
$query->whereHas('assignedEvents', fn($q) => $q->where('id', $eventId));
|
||||
$query->whereHas('assignedEvents', fn ($q) => $q->where('id', $eventId));
|
||||
}
|
||||
|
||||
$perPage = $request->get('per_page', 15);
|
||||
@@ -62,9 +61,6 @@ class TaskController extends Controller
|
||||
|
||||
/**
|
||||
* Store a newly created task in storage.
|
||||
*
|
||||
* @param TaskStoreRequest $request
|
||||
* @return JsonResponse
|
||||
*/
|
||||
public function store(TaskStoreRequest $request): JsonResponse
|
||||
{
|
||||
@@ -91,10 +87,6 @@ class TaskController extends Controller
|
||||
|
||||
/**
|
||||
* Display the specified task.
|
||||
*
|
||||
* @param Request $request
|
||||
* @param Task $task
|
||||
* @return JsonResponse
|
||||
*/
|
||||
public function show(Request $request, Task $task): JsonResponse
|
||||
{
|
||||
@@ -109,10 +101,6 @@ class TaskController extends Controller
|
||||
|
||||
/**
|
||||
* Update the specified task in storage.
|
||||
*
|
||||
* @param TaskUpdateRequest $request
|
||||
* @param Task $task
|
||||
* @return JsonResponse
|
||||
*/
|
||||
public function update(TaskUpdateRequest $request, Task $task): JsonResponse
|
||||
{
|
||||
@@ -142,10 +130,6 @@ class TaskController extends Controller
|
||||
|
||||
/**
|
||||
* Remove the specified task from storage.
|
||||
*
|
||||
* @param Request $request
|
||||
* @param Task $task
|
||||
* @return JsonResponse
|
||||
*/
|
||||
public function destroy(Request $request, Task $task): JsonResponse
|
||||
{
|
||||
@@ -162,11 +146,6 @@ class TaskController extends Controller
|
||||
|
||||
/**
|
||||
* Assign task to an event.
|
||||
*
|
||||
* @param Request $request
|
||||
* @param Task $task
|
||||
* @param Event $event
|
||||
* @return JsonResponse
|
||||
*/
|
||||
public function assignToEvent(Request $request, Task $task, Event $event): JsonResponse
|
||||
{
|
||||
@@ -187,10 +166,6 @@ class TaskController extends Controller
|
||||
|
||||
/**
|
||||
* Bulk assign tasks to an event.
|
||||
*
|
||||
* @param Request $request
|
||||
* @param Event $event
|
||||
* @return JsonResponse
|
||||
*/
|
||||
public function bulkAssignToEvent(Request $request, Event $event): JsonResponse
|
||||
{
|
||||
@@ -200,7 +175,12 @@ class TaskController extends Controller
|
||||
|
||||
$taskIds = $request->input('task_ids', []);
|
||||
if (empty($taskIds)) {
|
||||
return response()->json(['error' => 'Keine Task-IDs angegeben.'], 400);
|
||||
return ApiError::response(
|
||||
'task_ids_missing',
|
||||
'Keine Aufgaben angegeben',
|
||||
'Bitte wähle mindestens eine Aufgabe aus.',
|
||||
Response::HTTP_BAD_REQUEST
|
||||
);
|
||||
}
|
||||
|
||||
$tasks = Task::whereIn('id', $taskIds)
|
||||
@@ -209,7 +189,7 @@ class TaskController extends Controller
|
||||
|
||||
$attached = 0;
|
||||
foreach ($tasks as $task) {
|
||||
if (!$task->assignedEvents()->where('event_id', $event->id)->exists()) {
|
||||
if (! $task->assignedEvents()->where('event_id', $event->id)->exists()) {
|
||||
$task->assignedEvents()->attach($event->id);
|
||||
$attached++;
|
||||
}
|
||||
@@ -222,10 +202,6 @@ class TaskController extends Controller
|
||||
|
||||
/**
|
||||
* Get tasks for a specific event.
|
||||
*
|
||||
* @param Request $request
|
||||
* @param Event $event
|
||||
* @return AnonymousResourceCollection
|
||||
*/
|
||||
public function forEvent(Request $request, Event $event): AnonymousResourceCollection
|
||||
{
|
||||
@@ -233,7 +209,7 @@ class TaskController extends Controller
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$tasks = Task::whereHas('assignedEvents', fn($q) => $q->where('event_id', $event->id))
|
||||
$tasks = Task::whereHas('assignedEvents', fn ($q) => $q->where('event_id', $event->id))
|
||||
->with(['taskCollection'])
|
||||
->orderBy('created_at', 'desc')
|
||||
->paginate($request->get('per_page', 15));
|
||||
@@ -243,10 +219,6 @@ class TaskController extends Controller
|
||||
|
||||
/**
|
||||
* Get tasks from a specific collection.
|
||||
*
|
||||
* @param Request $request
|
||||
* @param TaskCollection $collection
|
||||
* @return AnonymousResourceCollection
|
||||
*/
|
||||
public function fromCollection(Request $request, TaskCollection $collection): AnonymousResourceCollection
|
||||
{
|
||||
@@ -321,9 +293,7 @@ class TaskController extends Controller
|
||||
}
|
||||
|
||||
/**
|
||||
* @param mixed $value
|
||||
* @param array<string, string>|null $fallback
|
||||
*
|
||||
* @param array<string, string>|null $fallback
|
||||
* @return array<string, string>|null
|
||||
*/
|
||||
protected function normalizeTranslations(mixed $value, ?array $fallback = null, bool $allowNull = false): ?array
|
||||
|
||||
@@ -6,32 +6,40 @@ use App\Models\Event;
|
||||
use App\Models\Photo;
|
||||
use App\Models\User;
|
||||
use App\Services\EventJoinTokenService;
|
||||
use App\Support\ApiError;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Routing\Controller as BaseController;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Str;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class TenantController extends BaseController
|
||||
{
|
||||
public function __construct(private readonly EventJoinTokenService $joinTokenService)
|
||||
{
|
||||
}
|
||||
public function __construct(private readonly EventJoinTokenService $joinTokenService) {}
|
||||
|
||||
public function login(Request $request)
|
||||
{
|
||||
$creds = $request->validate([
|
||||
'email' => ['required','email'],
|
||||
'password' => ['required','string'],
|
||||
'email' => ['required', 'email'],
|
||||
'password' => ['required', 'string'],
|
||||
]);
|
||||
if (! Auth::attempt($creds)) {
|
||||
return response()->json(['error' => ['code' => 'invalid_credentials']], 401);
|
||||
return ApiError::response(
|
||||
'invalid_credentials',
|
||||
'Invalid Credentials',
|
||||
'The provided credentials are incorrect.',
|
||||
Response::HTTP_UNAUTHORIZED,
|
||||
['email' => $creds['email']]
|
||||
);
|
||||
}
|
||||
/** @var User $user */
|
||||
$user = Auth::user();
|
||||
// naive token (cache-based), expires in 8 hours
|
||||
$token = Str::random(80);
|
||||
Cache::put('api_token:'.$token, $user->id, now()->addHours(8));
|
||||
|
||||
return response()->json([
|
||||
'token' => $token,
|
||||
'user' => [
|
||||
@@ -46,6 +54,7 @@ class TenantController extends BaseController
|
||||
public function me(Request $request)
|
||||
{
|
||||
$u = Auth::user();
|
||||
|
||||
return response()->json([
|
||||
'id' => $u->id,
|
||||
'name' => $u->name,
|
||||
@@ -62,7 +71,8 @@ class TenantController extends BaseController
|
||||
if ($tenantId) {
|
||||
$q->where('tenant_id', $tenantId);
|
||||
}
|
||||
return response()->json(['data' => $q->orderByDesc('created_at')->limit(100)->get(['id','name','slug','date','is_active'])]);
|
||||
|
||||
return response()->json(['data' => $q->orderByDesc('created_at')->limit(100)->get(['id', 'name', 'slug', 'date', 'is_active'])]);
|
||||
}
|
||||
|
||||
public function showEvent(int $id)
|
||||
@@ -71,9 +81,10 @@ class TenantController extends BaseController
|
||||
$tenantId = $u->tenant_id ?? null;
|
||||
$ev = Event::findOrFail($id);
|
||||
if ($tenantId && $ev->tenant_id !== $tenantId) {
|
||||
return response()->json(['error' => ['code' => 'forbidden']], 403);
|
||||
return $this->forbiddenResponse('events.show', ['event_id' => $ev->id, 'tenant_id' => $tenantId]);
|
||||
}
|
||||
return response()->json($ev->only(['id','name','slug','date','is_active','default_locale']));
|
||||
|
||||
return response()->json($ev->only(['id', 'name', 'slug', 'date', 'is_active', 'default_locale']));
|
||||
}
|
||||
|
||||
public function storeEvent(Request $request)
|
||||
@@ -81,19 +92,20 @@ class TenantController extends BaseController
|
||||
$u = Auth::user();
|
||||
$tenantId = $u->tenant_id ?? null;
|
||||
$data = $request->validate([
|
||||
'name' => ['required','string','max:255'],
|
||||
'slug' => ['required','string','max:255'],
|
||||
'date' => ['nullable','date'],
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'slug' => ['required', 'string', 'max:255'],
|
||||
'date' => ['nullable', 'date'],
|
||||
'is_active' => ['boolean'],
|
||||
]);
|
||||
$ev = new Event();
|
||||
$ev = new Event;
|
||||
$ev->tenant_id = $tenantId ?? $ev->tenant_id;
|
||||
$ev->name = ['de' => $data['name'], 'en' => $data['name']];
|
||||
$ev->slug = $data['slug'];
|
||||
$ev->date = $data['date'] ?? null;
|
||||
$ev->is_active = (bool)($data['is_active'] ?? true);
|
||||
$ev->is_active = (bool) ($data['is_active'] ?? true);
|
||||
$ev->default_locale = 'de';
|
||||
$ev->save();
|
||||
|
||||
return response()->json(['id' => $ev->id]);
|
||||
}
|
||||
|
||||
@@ -103,19 +115,28 @@ class TenantController extends BaseController
|
||||
$tenantId = $u->tenant_id ?? null;
|
||||
$ev = Event::findOrFail($id);
|
||||
if ($tenantId && $ev->tenant_id !== $tenantId) {
|
||||
return response()->json(['error' => ['code' => 'forbidden']], 403);
|
||||
return $this->forbiddenResponse('events.update', ['event_id' => $ev->id, 'tenant_id' => $tenantId]);
|
||||
}
|
||||
$data = $request->validate([
|
||||
'name' => ['nullable','string','max:255'],
|
||||
'slug' => ['nullable','string','max:255'],
|
||||
'date' => ['nullable','date'],
|
||||
'is_active' => ['nullable','boolean'],
|
||||
'name' => ['nullable', 'string', 'max:255'],
|
||||
'slug' => ['nullable', 'string', 'max:255'],
|
||||
'date' => ['nullable', 'date'],
|
||||
'is_active' => ['nullable', 'boolean'],
|
||||
]);
|
||||
if (isset($data['name'])) $ev->name = ['de' => $data['name'], 'en' => $data['name']];
|
||||
if (isset($data['slug'])) $ev->slug = $data['slug'];
|
||||
if (array_key_exists('date', $data)) $ev->date = $data['date'];
|
||||
if (array_key_exists('is_active', $data)) $ev->is_active = (bool)$data['is_active'];
|
||||
if (isset($data['name'])) {
|
||||
$ev->name = ['de' => $data['name'], 'en' => $data['name']];
|
||||
}
|
||||
if (isset($data['slug'])) {
|
||||
$ev->slug = $data['slug'];
|
||||
}
|
||||
if (array_key_exists('date', $data)) {
|
||||
$ev->date = $data['date'];
|
||||
}
|
||||
if (array_key_exists('is_active', $data)) {
|
||||
$ev->is_active = (bool) $data['is_active'];
|
||||
}
|
||||
$ev->save();
|
||||
|
||||
return response()->json(['ok' => true]);
|
||||
}
|
||||
|
||||
@@ -125,11 +146,12 @@ class TenantController extends BaseController
|
||||
$tenantId = $u->tenant_id ?? null;
|
||||
$ev = Event::findOrFail($id);
|
||||
if ($tenantId && $ev->tenant_id !== $tenantId) {
|
||||
return response()->json(['error' => ['code' => 'forbidden']], 403);
|
||||
return $this->forbiddenResponse('events.toggle', ['event_id' => $ev->id, 'tenant_id' => $tenantId]);
|
||||
}
|
||||
$ev->is_active = ! (bool) $ev->is_active;
|
||||
$ev->save();
|
||||
return response()->json(['is_active' => (bool)$ev->is_active]);
|
||||
|
||||
return response()->json(['is_active' => (bool) $ev->is_active]);
|
||||
}
|
||||
|
||||
public function eventStats(int $id)
|
||||
@@ -138,15 +160,16 @@ class TenantController extends BaseController
|
||||
$tenantId = $u->tenant_id ?? null;
|
||||
$ev = Event::findOrFail($id);
|
||||
if ($tenantId && $ev->tenant_id !== $tenantId) {
|
||||
return response()->json(['error' => ['code' => 'forbidden']], 403);
|
||||
return $this->forbiddenResponse('events.stats', ['event_id' => $ev->id, 'tenant_id' => $tenantId]);
|
||||
}
|
||||
$total = Photo::where('event_id', $id)->count();
|
||||
$featured = Photo::where('event_id', $id)->where('is_featured', 1)->count();
|
||||
$likes = Photo::where('event_id', $id)->sum('likes_count');
|
||||
|
||||
return response()->json([
|
||||
'total' => (int)$total,
|
||||
'featured' => (int)$featured,
|
||||
'likes' => (int)$likes,
|
||||
'total' => (int) $total,
|
||||
'featured' => (int) $featured,
|
||||
'likes' => (int) $likes,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -156,7 +179,7 @@ class TenantController extends BaseController
|
||||
$tenantId = $u->tenant_id ?? null;
|
||||
$ev = Event::findOrFail($id);
|
||||
if ($tenantId && $ev->tenant_id !== $tenantId) {
|
||||
return response()->json(['error' => ['code' => 'forbidden']], 403);
|
||||
return $this->forbiddenResponse('events.invite', ['event_id' => $ev->id, 'tenant_id' => $tenantId]);
|
||||
}
|
||||
|
||||
$joinToken = $this->joinTokenService->createToken($ev, [
|
||||
@@ -176,9 +199,10 @@ class TenantController extends BaseController
|
||||
$tenantId = $u->tenant_id ?? null;
|
||||
$ev = Event::findOrFail($id);
|
||||
if ($tenantId && $ev->tenant_id !== $tenantId) {
|
||||
return response()->json(['error' => ['code' => 'forbidden']], 403);
|
||||
return $this->forbiddenResponse('events.photos', ['event_id' => $ev->id, 'tenant_id' => $tenantId]);
|
||||
}
|
||||
$rows = Photo::where('event_id', $id)->orderByDesc('created_at')->limit(100)->get(['id','thumbnail_path','file_path','likes_count','is_featured','created_at']);
|
||||
$rows = Photo::where('event_id', $id)->orderByDesc('created_at')->limit(100)->get(['id', 'thumbnail_path', 'file_path', 'likes_count', 'is_featured', 'created_at']);
|
||||
|
||||
return response()->json(['data' => $rows]);
|
||||
}
|
||||
|
||||
@@ -186,7 +210,9 @@ class TenantController extends BaseController
|
||||
{
|
||||
$p = Photo::findOrFail($photoId);
|
||||
$this->authorizePhoto($p);
|
||||
$p->is_featured = 1; $p->save();
|
||||
$p->is_featured = 1;
|
||||
$p->save();
|
||||
|
||||
return response()->json(['ok' => true]);
|
||||
}
|
||||
|
||||
@@ -194,7 +220,9 @@ class TenantController extends BaseController
|
||||
{
|
||||
$p = Photo::findOrFail($photoId);
|
||||
$this->authorizePhoto($p);
|
||||
$p->is_featured = 0; $p->save();
|
||||
$p->is_featured = 0;
|
||||
$p->save();
|
||||
|
||||
return response()->json(['ok' => true]);
|
||||
}
|
||||
|
||||
@@ -203,9 +231,21 @@ class TenantController extends BaseController
|
||||
$p = Photo::findOrFail($photoId);
|
||||
$this->authorizePhoto($p);
|
||||
$p->delete();
|
||||
|
||||
return response()->json(['ok' => true]);
|
||||
}
|
||||
|
||||
private function forbiddenResponse(string $action, array $meta = []): JsonResponse
|
||||
{
|
||||
return ApiError::response(
|
||||
'forbidden',
|
||||
'Forbidden',
|
||||
'You are not allowed to perform this action.',
|
||||
Response::HTTP_FORBIDDEN,
|
||||
array_merge(['action' => $action], $meta)
|
||||
);
|
||||
}
|
||||
|
||||
protected function authorizePhoto(Photo $p): void
|
||||
{
|
||||
$u = Auth::user();
|
||||
|
||||
@@ -4,8 +4,10 @@ namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\TenantPackage;
|
||||
use Illuminate\Http\Request;
|
||||
use App\Support\ApiError;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class TenantPackageController extends Controller
|
||||
{
|
||||
@@ -13,8 +15,13 @@ class TenantPackageController extends Controller
|
||||
{
|
||||
$tenant = $request->attributes->get('tenant');
|
||||
|
||||
if (!$tenant) {
|
||||
return response()->json(['error' => 'Tenant not found.'], 404);
|
||||
if (! $tenant) {
|
||||
return ApiError::response(
|
||||
'tenant_not_found',
|
||||
'Tenant Not Found',
|
||||
'The authenticated tenant context could not be resolved.',
|
||||
Response::HTTP_NOT_FOUND
|
||||
);
|
||||
}
|
||||
|
||||
$packages = TenantPackage::where('tenant_id', $tenant->id)
|
||||
@@ -33,4 +40,4 @@ class TenantPackageController extends Controller
|
||||
'message' => 'Tenant packages loaded successfully.',
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ use App\Models\OAuthCode;
|
||||
use App\Models\RefreshToken;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\TenantToken;
|
||||
use App\Support\ApiError;
|
||||
use Firebase\JWT\JWT;
|
||||
use GuzzleHttp\Client;
|
||||
use Illuminate\Http\Request;
|
||||
@@ -17,6 +18,7 @@ use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Support\Str;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class OAuthController extends Controller
|
||||
{
|
||||
@@ -690,7 +692,12 @@ class OAuthController extends Controller
|
||||
{
|
||||
$tenant = $request->user()->tenant ?? null;
|
||||
if (! $tenant) {
|
||||
return response()->json(['error' => 'Tenant not found'], 404);
|
||||
return ApiError::response(
|
||||
'tenant_not_found',
|
||||
'Tenant not found',
|
||||
'The authenticated user is not assigned to a tenant.',
|
||||
Response::HTTP_NOT_FOUND
|
||||
);
|
||||
}
|
||||
|
||||
$state = Str::random(40);
|
||||
|
||||
@@ -3,9 +3,11 @@
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Jobs\ProcessRevenueCatWebhook;
|
||||
use App\Support\ApiError;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class RevenueCatWebhookController extends Controller
|
||||
{
|
||||
@@ -15,17 +17,33 @@ class RevenueCatWebhookController extends Controller
|
||||
|
||||
if ($secret === '') {
|
||||
Log::error('RevenueCat webhook secret not configured');
|
||||
return response()->json(['error' => 'Webhook not configured'], 500);
|
||||
|
||||
return ApiError::response(
|
||||
'webhook_not_configured',
|
||||
'Webhook Not Configured',
|
||||
'RevenueCat webhook secret is missing.',
|
||||
Response::HTTP_INTERNAL_SERVER_ERROR
|
||||
);
|
||||
}
|
||||
|
||||
$signature = trim((string) $request->header('X-Signature', ''));
|
||||
if ($signature === '') {
|
||||
return response()->json(['error' => 'Signature missing'], 400);
|
||||
return ApiError::response(
|
||||
'signature_missing',
|
||||
'Signature Missing',
|
||||
'The RevenueCat webhook request did not include a signature.',
|
||||
Response::HTTP_BAD_REQUEST
|
||||
);
|
||||
}
|
||||
|
||||
$payload = $request->getContent();
|
||||
if (! $this->signatureMatches($payload, $signature, $secret)) {
|
||||
return response()->json(['error' => 'Invalid signature'], 400);
|
||||
return ApiError::response(
|
||||
'signature_invalid',
|
||||
'Invalid Signature',
|
||||
'The webhook signature could not be validated.',
|
||||
Response::HTTP_BAD_REQUEST
|
||||
);
|
||||
}
|
||||
|
||||
$decoded = json_decode($payload, true);
|
||||
@@ -33,7 +51,14 @@ class RevenueCatWebhookController extends Controller
|
||||
Log::warning('RevenueCat webhook received invalid JSON', [
|
||||
'error' => json_last_error_msg(),
|
||||
]);
|
||||
return response()->json(['error' => 'Invalid payload'], 400);
|
||||
|
||||
return ApiError::response(
|
||||
'payload_invalid',
|
||||
'Invalid Payload',
|
||||
'The webhook payload could not be decoded as JSON.',
|
||||
Response::HTTP_BAD_REQUEST,
|
||||
['json_error' => json_last_error_msg()]
|
||||
);
|
||||
}
|
||||
|
||||
ProcessRevenueCatWebhook::dispatch(
|
||||
|
||||
@@ -2,14 +2,15 @@
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use App\Models\Package;
|
||||
use App\Support\ApiError;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Stripe\Stripe;
|
||||
use Stripe\PaymentIntent;
|
||||
use App\Models\Package;
|
||||
use App\Models\Tenant;
|
||||
use Stripe\Stripe;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class StripePaymentController extends Controller
|
||||
{
|
||||
@@ -25,13 +26,23 @@ class StripePaymentController extends Controller
|
||||
]);
|
||||
|
||||
$user = Auth::user();
|
||||
if (!$user) {
|
||||
return response()->json(['error' => 'Nicht authentifiziert'], 401);
|
||||
if (! $user) {
|
||||
return ApiError::response(
|
||||
'unauthenticated',
|
||||
'Nicht authentifiziert',
|
||||
'Bitte melde dich an, um einen Kauf zu starten.',
|
||||
Response::HTTP_UNAUTHORIZED
|
||||
);
|
||||
}
|
||||
|
||||
$tenant = $user->tenant;
|
||||
if (!$tenant) {
|
||||
return response()->json(['error' => 'Kein Tenant gefunden'], 403);
|
||||
if (! $tenant) {
|
||||
return ApiError::response(
|
||||
'tenant_not_found',
|
||||
'Tenant nicht gefunden',
|
||||
'Für dein Benutzerkonto konnte kein Tenant gefunden werden.',
|
||||
Response::HTTP_FORBIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
$package = Package::findOrFail($request->package_id);
|
||||
@@ -40,13 +51,13 @@ class StripePaymentController extends Controller
|
||||
if ($package->price <= 0) {
|
||||
return response()->json([
|
||||
'type' => 'free',
|
||||
'message' => 'Kostenloses Paket - kein Payment Intent nötig'
|
||||
'message' => 'Kostenloses Paket - kein Payment Intent nötig',
|
||||
]);
|
||||
}
|
||||
|
||||
try {
|
||||
$paymentIntent = PaymentIntent::create([
|
||||
'amount' => (int)($package->price * 100), // In Cent
|
||||
'amount' => (int) ($package->price * 100), // In Cent
|
||||
'currency' => 'eur',
|
||||
'metadata' => [
|
||||
'package_id' => $package->id,
|
||||
@@ -65,7 +76,7 @@ class StripePaymentController extends Controller
|
||||
'payment_intent_id' => $paymentIntent->id,
|
||||
'package_id' => $package->id,
|
||||
'tenant_id' => $tenant->id,
|
||||
'amount' => $package->price
|
||||
'amount' => $package->price,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
@@ -76,10 +87,16 @@ class StripePaymentController extends Controller
|
||||
Log::error('Stripe Payment Intent Fehler', [
|
||||
'error' => $e->getMessage(),
|
||||
'package_id' => $request->package_id,
|
||||
'user_id' => $user->id
|
||||
'user_id' => $user->id,
|
||||
]);
|
||||
|
||||
return response()->json(['error' => $e->getMessage()], 400);
|
||||
return ApiError::response(
|
||||
'stripe_payment_error',
|
||||
'Stripe Fehler',
|
||||
'Die Zahlung konnte nicht vorbereitet werden.',
|
||||
Response::HTTP_BAD_REQUEST,
|
||||
['stripe_message' => $e->getMessage()]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user