Der Kontotyp ist kein verlaesslicher Marker (connektar ist agency,
nicht apiuser) — submitForReview traegt jetzt die Quelle ('customer'
Panel, 'api' Legacy/v1) ins Status-Log, notifyAuthor prueft den
letzten Review-Uebergang. Kein Schema-Change noetig.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
270 lines
10 KiB
PHP
270 lines
10 KiB
PHP
<?php
|
||
|
||
namespace App\Http\Controllers\Api\V1;
|
||
|
||
use App\Enums\AiTextDeclaration;
|
||
use App\Enums\PressReleaseStatus;
|
||
use App\Http\Controllers\Controller;
|
||
use App\Http\Requests\Api\V1\StorePressReleaseRequest;
|
||
use App\Http\Requests\Api\V1\UpdatePressReleaseRequest;
|
||
use App\Http\Resources\PressReleaseResource;
|
||
use App\Models\Company;
|
||
use App\Models\PressRelease;
|
||
use App\Services\PressRelease\AiDeclarationRequiredException;
|
||
use App\Services\PressRelease\BlacklistViolationException;
|
||
use App\Services\PressRelease\BookingRequiredException;
|
||
use App\Services\PressRelease\PressReleaseService;
|
||
use App\Services\PressRelease\QuotaExceededException;
|
||
use Illuminate\Http\JsonResponse;
|
||
use Illuminate\Http\Request;
|
||
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||
use Illuminate\Http\Response;
|
||
use Illuminate\Support\Str;
|
||
use Illuminate\Validation\Rule;
|
||
|
||
class PressReleaseController extends Controller
|
||
{
|
||
public function index(Request $request): AnonymousResourceCollection
|
||
{
|
||
abort_unless($request->user()->tokenCan('press-releases:read'), 403);
|
||
|
||
$pressReleases = PressRelease::withoutGlobalScopes()
|
||
->where('user_id', $request->user()->id)
|
||
->with($this->apiResourceRelations())
|
||
->when($request->query('status'), fn ($query, string $status) => $query->where('status', $status))
|
||
->latest()
|
||
->paginate(min((int) $request->query('per_page', 25), 100));
|
||
|
||
return PressReleaseResource::collection($pressReleases);
|
||
}
|
||
|
||
public function store(StorePressReleaseRequest $request): JsonResponse
|
||
{
|
||
$validated = $request->validated();
|
||
$company = $this->findOwnedCompany((int) $validated['company_id'], $request);
|
||
abort_unless($company !== null, 403);
|
||
|
||
$pressRelease = PressRelease::withoutGlobalScopes()->create([
|
||
...$validated,
|
||
'uuid' => (string) Str::uuid(),
|
||
'user_id' => $request->user()->id,
|
||
'portal' => $company->portal->value,
|
||
'slug' => $this->uniqueSlug(
|
||
Str::slug($validated['title']),
|
||
$company->portal->value,
|
||
$validated['language'],
|
||
),
|
||
// Über die API angelegte PMs sind immer Entwürfe. Der Übergang nach
|
||
// `review` erfolgt ausschließlich über die explizite submit-Route,
|
||
// damit Blacklist-/Quota-/Log-Prüfung garantiert durchlaufen werden.
|
||
'status' => PressReleaseStatus::Draft->value,
|
||
// Legacy-/service-Clients kennen die KI-Kennzeichnung (Art. 50
|
||
// KI-VO) nicht — Entscheidung 10.07.: fehlende Angabe gilt als
|
||
// „ohne generative KI erstellt"; das API-Kunden-Mailing (R6.4)
|
||
// weist darauf hin, dass die weitere Nutzung diese Erklärung
|
||
// darstellt. v1-Clients deklarieren explizit beim Submit.
|
||
...($this->isLegacyServiceRequest($request) ? [
|
||
'ai_text_declaration' => AiTextDeclaration::None->value,
|
||
'ai_declaration_confirmed_at' => now(),
|
||
] : []),
|
||
]);
|
||
|
||
return PressReleaseResource::make(
|
||
$pressRelease->load($this->apiResourceRelations())
|
||
)->response()->setStatusCode(201);
|
||
}
|
||
|
||
public function show(Request $request, int $pressRelease): PressReleaseResource
|
||
{
|
||
abort_unless($request->user()->tokenCan('press-releases:read'), 403);
|
||
$pressRelease = $this->findOwnedPressRelease($pressRelease, $request);
|
||
abort_unless($pressRelease !== null, 403);
|
||
|
||
return PressReleaseResource::make(
|
||
$pressRelease->load($this->apiResourceRelations())
|
||
);
|
||
}
|
||
|
||
public function update(UpdatePressReleaseRequest $request, int $pressRelease): PressReleaseResource|JsonResponse
|
||
{
|
||
$pressRelease = $this->findOwnedPressRelease($pressRelease, $request);
|
||
abort_unless($pressRelease !== null, 403);
|
||
|
||
if (! in_array($pressRelease->status->value, ['draft', 'rejected'], true)) {
|
||
return response()->json([
|
||
'message' => 'Only draft or rejected press releases may be edited.',
|
||
], 409);
|
||
}
|
||
|
||
$validated = $request->validated();
|
||
$company = isset($validated['company_id'])
|
||
? $this->findOwnedCompany((int) $validated['company_id'], $request)
|
||
: $this->findOwnedCompany((int) $pressRelease->company_id, $request);
|
||
abort_unless($company !== null, 403);
|
||
|
||
$language = $validated['language'] ?? $pressRelease->language;
|
||
$title = $validated['title'] ?? $pressRelease->title;
|
||
$portal = $company->portal->value;
|
||
|
||
$pressRelease->fill([
|
||
...$validated,
|
||
'portal' => $portal,
|
||
]);
|
||
|
||
if (
|
||
array_key_exists('title', $validated)
|
||
|| array_key_exists('language', $validated)
|
||
|| array_key_exists('company_id', $validated)
|
||
) {
|
||
$pressRelease->slug = $this->uniqueSlug(Str::slug($title), $portal, $language, $pressRelease->id);
|
||
}
|
||
|
||
$pressRelease->save();
|
||
|
||
// Inhaltliche Änderung einer bereits geprüften/bewerteten PM → neu prüfen.
|
||
if ($pressRelease->wasChanged(['title', 'text'])) {
|
||
$service = app(PressReleaseService::class);
|
||
$service->reclassifyIfClassified($pressRelease);
|
||
$service->rescoreIfScored($pressRelease);
|
||
}
|
||
|
||
return PressReleaseResource::make(
|
||
$pressRelease->load($this->apiResourceRelations())
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Reicht eine PM zur Prüfung ein – der einzige API-Weg nach `review`.
|
||
*
|
||
* Kapselt denselben Funnel wie das Web-Formular: Blacklist-Hard-Filter,
|
||
* Statuswechsel, Status-Log und Quota werden über den
|
||
* `PressReleaseService` garantiert durchlaufen. `published` bleibt über die
|
||
* API unerreichbar.
|
||
*/
|
||
public function submit(Request $request, int $pressRelease, PressReleaseService $service): PressReleaseResource|JsonResponse
|
||
{
|
||
abort_unless($request->user()->tokenCan('press-releases:write'), 403);
|
||
|
||
// KI-Kennzeichnungspflicht (Art. 50 KI-VO) auch via API: der
|
||
// Submit-Call trägt den deklarierten KI-Grad des Textes und gilt
|
||
// dokumentiert als verbindliche Bestätigung der Deklaration sowie
|
||
// Kenntnisnahme der dauerhaften Archivierung (AGB § 13) —
|
||
// Entscheidung 10.07.2026.
|
||
$validated = $request->validate([
|
||
'ai_text_declaration' => ['required', Rule::enum(AiTextDeclaration::class)],
|
||
]);
|
||
|
||
$pressRelease = $this->findOwnedPressRelease($pressRelease, $request);
|
||
abort_unless($pressRelease !== null, 403);
|
||
|
||
if (! in_array($pressRelease->status->value, ['draft', 'rejected'], true)) {
|
||
return response()->json([
|
||
'message' => 'Only draft or rejected press releases may be submitted for review.',
|
||
], 409);
|
||
}
|
||
|
||
$pressRelease->update([
|
||
'ai_text_declaration' => $validated['ai_text_declaration'],
|
||
'ai_declaration_confirmed_at' => now(),
|
||
]);
|
||
|
||
try {
|
||
$service->submitForReview($pressRelease, 'api');
|
||
} catch (BookingRequiredException $exception) {
|
||
return response()->json([
|
||
'message' => $exception->getMessage(),
|
||
], 402);
|
||
} catch (QuotaExceededException $exception) {
|
||
return response()->json([
|
||
'message' => $exception->getMessage(),
|
||
], 422);
|
||
} catch (AiDeclarationRequiredException $exception) {
|
||
return response()->json([
|
||
'message' => $exception->getMessage(),
|
||
], 422);
|
||
} catch (BlacklistViolationException $exception) {
|
||
return response()->json([
|
||
'message' => $exception->getMessage(),
|
||
], 422);
|
||
}
|
||
|
||
return PressReleaseResource::make(
|
||
$pressRelease->fresh()->load($this->apiResourceRelations())
|
||
);
|
||
}
|
||
|
||
public function destroy(Request $request, int $pressRelease): JsonResponse|Response
|
||
{
|
||
abort_unless($request->user()->tokenCan('press-releases:write'), 403);
|
||
$pressRelease = $this->findOwnedPressRelease($pressRelease, $request);
|
||
abort_unless($pressRelease !== null, 403);
|
||
|
||
if ($pressRelease->status->value === 'published') {
|
||
return response()->json([
|
||
'message' => 'Published press releases cannot be deleted via API.',
|
||
], 409);
|
||
}
|
||
|
||
$pressRelease->delete();
|
||
|
||
return response()->noContent();
|
||
}
|
||
|
||
private function findOwnedCompany(int $companyId, Request $request): ?Company
|
||
{
|
||
return Company::withoutGlobalScopes()
|
||
->whereKey($companyId)
|
||
->accessibleBy($request->user())
|
||
->first();
|
||
}
|
||
|
||
/**
|
||
* Relationen für die API-Resource-Ausgabe. Zentral, damit Index, Show,
|
||
* Create, Update und Reclassify denselben Satz laden.
|
||
*
|
||
* @return list<string>
|
||
*/
|
||
private function apiResourceRelations(): array
|
||
{
|
||
return ['company:id,name,portal,legacy_portal,legacy_id', 'category.translations', 'images'];
|
||
}
|
||
|
||
private function findOwnedPressRelease(int $pressReleaseId, Request $request): ?PressRelease
|
||
{
|
||
return PressRelease::withoutGlobalScopes()
|
||
->whereKey($pressReleaseId)
|
||
->ownedByApiUser($request->user())
|
||
->first();
|
||
}
|
||
|
||
/**
|
||
* Kam der Call über die Legacy-Aliasse (/service/…, routes/legacy-api.php)?
|
||
* Dort gelten Kompatibilitäts-Defaults für Felder, die die Altportale
|
||
* nicht kannten (KI-Kennzeichnung).
|
||
*/
|
||
private function isLegacyServiceRequest(Request $request): bool
|
||
{
|
||
return str_starts_with((string) $request->route()?->getName(), 'legacy-service.');
|
||
}
|
||
|
||
private function uniqueSlug(string $baseSlug, string $portal, string $language, ?int $excludeId = null): string
|
||
{
|
||
$slug = $baseSlug !== '' ? $baseSlug : Str::random(8);
|
||
$candidate = $slug;
|
||
$suffix = 2;
|
||
|
||
while (
|
||
PressRelease::withoutGlobalScopes()
|
||
->where('portal', $portal)
|
||
->where('language', $language)
|
||
->where('slug', $candidate)
|
||
->when($excludeId !== null, fn ($query) => $query->whereKeyNot($excludeId))
|
||
->exists()
|
||
) {
|
||
$candidate = "{$slug}-{$suffix}";
|
||
$suffix++;
|
||
}
|
||
|
||
return $candidate;
|
||
}
|
||
}
|