user()->tokenCan('press-releases:read'), 403); $pressRelease = $this->findOwnedPressRelease($pressRelease, $request); abort_unless($pressRelease !== null, 403); return PressReleaseImageResource::collection( $pressRelease->images()->orderBy('sort_order')->orderBy('id')->get() ); } public function store(StorePressReleaseImageRequest $request, int $pressRelease): JsonResponse { $pressRelease = $this->findOwnedPressRelease($pressRelease, $request); abort_unless($pressRelease !== null, 403); if (! $this->canChangeImages($pressRelease)) { return response()->json([ 'message' => 'Only draft or rejected press releases may be edited.', ], 409); } $validated = $request->validated(); /** @var UploadedFile $file */ $file = $request->file('image'); // Formular v2 (Bildkennzeichnung Block C): derselbe Ablauf wie im // Kundenformular — Original, Herkunftsprüfung, Bestätigung mit // Token-ID. Ein zweites Bild wird abgelehnt (ein Titelbild je PM). if (config('images.rights_v2')) { return $this->storeUnderRightsV2($request, $pressRelease, $validated, $file); } try { $stored = $this->imageService->storePressReleaseImage($file, $pressRelease->id); } catch (RuntimeException $exception) { return response()->json([ 'message' => $exception->getMessage(), ], 422); } if ((bool) ($validated['is_preview'] ?? false)) { $pressRelease->images()->update(['is_preview' => false]); } $image = $pressRelease->images()->create([ 'disk' => $stored['disk'], 'path' => $stored['path'], 'variants' => $stored['variants'], // v1 verlangt die KI-Kennzeichnung je Bild (FormRequest). 'ai_declaration' => $validated['ai_declaration'], 'title' => $validated['title'] ?? null, 'description' => $validated['description'] ?? null, 'copyright' => $validated['copyright'] ?? null, 'is_preview' => (bool) ($validated['is_preview'] ?? false), 'sort_order' => ((int) $pressRelease->images()->max('sort_order')) + 1, 'width' => $stored['width'], 'height' => $stored['height'], 'mime' => $stored['mime'], ]); return PressReleaseImageResource::make($image) ->response() ->setStatusCode(201); } public function destroy(Request $request, int $pressReleaseImage): Response|JsonResponse { abort_unless($request->user()->tokenCan('press-release-images:write'), 403); $image = PressReleaseImage::query() ->whereKey($pressReleaseImage) ->whereHas('pressRelease', fn ($query) => $query->withoutGlobalScope(PortalScope::class)->where('user_id', $request->user()->id)) ->with(['pressRelease' => fn ($query) => $query->withoutGlobalScope(PortalScope::class)]) ->first(); abort_unless($image !== null, 403); if (! $this->canChangeImages($image->pressRelease)) { return response()->json([ 'message' => 'Only draft or rejected press releases may be edited.', ], 409); } if (config('images.rights_v2')) { // Status-Regel unter der Sperre erneut prüfen (Umsetzungsaudit // 29.09., B-05): Ein gleichzeitiges Einreichen gewinnt. try { $this->remover->remove($image, $request->user(), allowedStatuses: [PressReleaseStatus::Draft, PressReleaseStatus::Rejected]); } catch (ImageUploadException) { return response()->json([ 'message' => 'Only draft or rejected press releases may be edited.', ], 409); } } else { $this->imageService->deletePressReleaseImage($image->disk, $image->path, $image->variants); $image->delete(); } return response()->noContent(); } /** * @param array $validated */ private function storeUnderRightsV2(StorePressReleaseImageRequest $request, PressRelease $pressRelease, array $validated, UploadedFile $file): JsonResponse { $focus = isset($validated['focus_x'], $validated['focus_y']) ? ['x' => (int) $validated['focus_x'], 'y' => (int) $validated['focus_y']] : null; $submission = new ImageSubmission( copyright: (string) $validated['copyright'], aiDeclaration: AiImageDeclaration::from($validated['ai_declaration']), rightsConfirmed: (bool) $validated['rights_confirmed'], legalTextKey: (string) $validated['confirm_text_version'], title: $validated['title'] ?? null, description: $validated['description'] ?? null, licenseUrl: $validated['license_url'] ?? null, sourceUrl: $validated['source_url'] ?? null, focus: $focus, noCrop: (bool) ($validated['no_crop'] ?? false), ); // Token-ID fürs Protokoll über den Bearer-Header auflösen: Sanctums // currentAccessToken() ist im Test ein TransientToken ohne Datensatz; // ohne Bearer bleibt die ID leer, der Kontoinhaber steht trotzdem drin. $bearer = $request->bearerToken(); $token = $bearer !== null ? PersonalAccessToken::findToken($bearer) : null; $actor = ImageUploadActor::api( $request->user(), $token !== null ? (int) $token->getKey() : null, $request->ip(), $request->userAgent(), ); try { $image = $this->uploader->store( $file, $pressRelease, $submission, $actor, allowedStatuses: [PressReleaseStatus::Draft, PressReleaseStatus::Rejected], ); } catch (ImageUploadException $exception) { return response()->json([ 'message' => $exception->getMessage(), 'errors' => [$exception->field => [$exception->getMessage()]], ], 422); } catch (RuntimeException $exception) { return response()->json([ 'message' => $exception->getMessage(), ], 422); } return PressReleaseImageResource::make($image->load('latestRightsConfirmation')) ->response() ->setStatusCode(201); } private function findOwnedPressRelease(int $pressReleaseId, Request $request): ?PressRelease { return PressRelease::withoutGlobalScope(PortalScope::class) ->whereKey($pressReleaseId) ->ownedByApiUser($request->user()) ->first(); } private function canChangeImages(PressRelease $pressRelease): bool { $status = $pressRelease->status instanceof PressReleaseStatus ? $pressRelease->status->value : (string) $pressRelease->status; return in_array($status, [PressReleaseStatus::Draft->value, PressReleaseStatus::Rejected->value], true); } }