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

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