Limit-Status im Upload-Flow anzeigen (Warnbanner + Sperrzustände).

Upload-Fehlercodes auswerten und freundliche Dialoge zeigen.
This commit is contained in:
Codex Agent
2025-11-01 19:50:17 +01:00
parent 2c14493604
commit 79b209de9a
55 changed files with 3348 additions and 462 deletions

View File

@@ -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

View File

@@ -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;

View File

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

View File

@@ -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([

View File

@@ -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');

View File

@@ -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)) {

View File

@@ -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

View File

@@ -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();

View File

@@ -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.',
]);
}
}
}

View File

@@ -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);

View File

@@ -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(

View File

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