Complete grouped collaborative editorial queues and native review coverage
tests / ci (push) Waiting to run

This commit is contained in:
2026-11-14 11:15:09 +02:00
parent f40924f1be
commit a4541298c0
36 changed files with 2691 additions and 89 deletions
@@ -5,11 +5,15 @@ namespace App\Http\Controllers;
use App\Models\User;
use App\Services\ContentManagement;
use App\Services\DocumentManagementAccess;
use App\Services\PortalEditorialNativeNotices;
use App\Services\PortalEditorialSubjects;
use App\Services\PortalEditorialWorkflow;
use App\Services\PortalProfileAccess;
use App\Services\TerritorialRegistry;
use App\Services\VerifiedDocumentBinary;
use App\Support\CheckedFile;
use Illuminate\Http\Request;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Inertia\Inertia;
@@ -17,6 +21,64 @@ use Symfony\Component\HttpFoundation\Response;
final class PortalEditorialWorkflowController extends Controller
{
public function subjects(Request $request, PortalEditorialWorkflow $workflow): Response
{
$adapter = app(PortalEditorialSubjects::class);
abort_unless($adapter->available() && $request->user()->can('manage-portal') && ! app(PortalProfileAccess::class)->has($request->user(), 'auditor'), 403);
$items = $workflow->subjectQueue()->orderByDesc('e.updated_at')->paginate(25, ['e.id', 'e.subject_kind', 'e.subject_key', 'e.revision_no', 'e.state', DB::raw("e.payload->'resource'->>'title' AS title")]);
return $this->screen($request, 'management/editorial-subjects', ['items' => $items]);
}
public function subject(Request $request, string $kind, string $id, PortalEditorialWorkflow $workflow): Response
{
$adapter = app(PortalEditorialSubjects::class);
$adapter->authorize($request->user(), $kind, $id);
$record = $adapter->query($kind, $id)->firstOrFail();
$selected = $request->validate(['edition' => 'nullable|uuid'])['edition'] ?? null;
$latest = $workflow->latestSubject($kind, $id);
$editions = DB::table('portal_editorial_editions')->where('subject_kind', $kind)->where('subject_key', $id);
$edition = $selected ? (clone $editions)->where('id', $selected)->first() : $latest;
abort_if($selected && ! $edition, 404);
$payload = $edition ? $workflow->payload($edition) : null;
$independent = $edition ? $workflow->requiresIndependent($edition) : $adapter->independent($kind);
$canApprove = ! $independent || ! in_array($request->user()->id, $payload['contributors'] ?? [], true);
return $this->screen($request, 'management/editorial-edition', ['subject' => 'structural', 'subjectKind' => $kind, 'editUrl' => $adapter->editUrl($kind, $record), 'subjectFields' => $adapter->form($kind), 'subjectInitial' => Arr::only(get_object_vars($record), $adapter->definition($kind)['fields']),
'resource' => ['id' => $id, 'title' => $record->name ?? $record->title ?? $kind, 'published' => ($record->deleted_at ?? null) === null && ($record->validation_status ?? null) !== 'revoked' && (clone $editions)->where('state', 'published')->exists(), 'public_edition_no' => (clone $editions)->where('state', 'published')->max('revision_no')],
'edition' => $edition ? [...get_object_vars($edition), 'payload' => $payload, 'fresh' => $edition->state === 'published' || $workflow->fresh($edition)] : null,
'current' => $edition && $edition->id === $latest?->id, 'independentRequired' => $independent, 'canApprove' => $canApprove, 'manager' => true, 'legalReviewer' => false,
'reviewers' => User::whereIn('email', config('portal.managers', []))->whereNotNull('email_verified_at')->get(['id', 'name']),
'history' => DB::table('audit_events')->where('source_system', 'document-access-v1')->where('action', 'like', 'editorial_%')->where('after_data->subject_kind', $kind)->where('after_data->subject_key', $id)->orderByDesc('id')->get(['id', 'action', 'actor_user_id', 'after_data', 'occurred_at']),
'editions' => $editions->orderByDesc('revision_no')->get(['id', 'revision_no', 'state', 'content_hash', 'author_id', 'created_at'])]);
}
public function subjectTransition(Request $request, string $kind, string $id, string $edition, PortalEditorialWorkflow $workflow): Response
{
$result = $workflow->transition($request->user(), $id, $edition, $request->all(), false, false, $kind);
return $request->expectsJson() ? response()->json(['edition' => $result])->header('Cache-Control', 'private, no-store') : back()->with('success', 'Operação editorial registada.');
}
public function subjectSave(Request $request, string $kind, string $id, PortalEditorialWorkflow $workflow): Response
{
$adapter = app(PortalEditorialSubjects::class);
$adapter->authorize($request->user(), $kind, $id, true);
$data = $request->validate(['expected_revision' => 'required|integer|min:1', 'fields' => 'required|array', 'withdraw' => 'sometimes|boolean']);
$edition = $workflow->saveSubject($request->user(), $kind, $id, (int) $data['expected_revision'], $adapter->validate($kind, $data['fields']), false, $data['withdraw'] ?? null);
return $request->expectsJson() ? response()->json(['edition' => $edition], 201)->header('Cache-Control', 'private, no-store') : redirect('/management/editorial/subjects/'.$kind.'/'.$id)->with('success', 'Proposta privada guardada.');
}
public function subjectChoices(Request $request, string $kind, string $id): Response
{
$adapter = app(PortalEditorialSubjects::class);
$adapter->authorize($request->user(), $kind, $id);
$data = $request->validate(['field' => 'required|string|max:80', 'q' => 'nullable|string|max:100']);
return response()->json(['data' => $adapter->choices($kind, $data['field'], $data['q'] ?? null), 'limit' => 100])->header('Cache-Control', 'private, no-store');
}
private const KIND = "CASE WHEN e.payload->'resource'->>'legislation' = 'true' OR e.payload->'resource'->>'document_type' = 'legislation' THEN 'legislation' WHEN e.payload->'resource'->>'editorial_content' IS NOT NULL THEN 'article' ELSE 'document' END";
/** @param array<string,mixed> $data */
@@ -45,7 +107,9 @@ final class PortalEditorialWorkflowController extends Controller
$destinations = app(ContentManagement::class)->access($actor);
$data = ['items' => $items, 'filters' => $filters, 'notices' => $workflow->notices($actor), 'resourceAccess' => $access->full($actor) || $access->editorial($actor), 'opportunitiesUrl' => $destinations['opportunities']['index'] ?? null, 'opportunityPending' => $workflow->opportunityPending($actor), 'clustersUrl' => $destinations['clusters']['index'] ?? null,
'portfolioUrl' => $destinations['portfolio']['index'] ?? null, 'portfolioPending' => $workflow->portfolioPending($actor), 'demographyUrl' => $destinations['demography']['index'] ?? null, 'demographyPending' => $workflow->demographyPending($actor),
'territorialUrl' => app(TerritorialRegistry::class)->editorialAvailable() && $actor->can('manage-portal') ? '/management/territorial/editorial' : null];
'territorialUrl' => app(TerritorialRegistry::class)->editorialAvailable() && $actor->can('manage-portal') ? '/management/territorial/editorial' : null,
'nativePending' => app(PortalEditorialNativeNotices::class)->pending($actor), 'zonesUrl' => app(PortalEditorialNativeNotices::class)->access($actor, 'investment-zones') ? '/management/investment/zones' : null,
'subjectsUrl' => app(PortalEditorialSubjects::class)->available() && $actor->can('manage-portal') ? '/management/editorial/subjects' : null, 'subjectsPending' => $workflow->subjectPending($actor)];
return $this->screen($request, 'management/editorial-workflow', $data);
}
@@ -67,7 +131,7 @@ final class PortalEditorialWorkflowController extends Controller
'canApprove' => $independent && app(DocumentManagementAccess::class)->full($request->user()),
'manager' => app(DocumentManagementAccess::class)->full($request->user()),
'reviewers' => User::whereIn('email', config('portal.managers', []))->whereNotNull('email_verified_at')->get(['id', 'name']),
'legalReviewer' => in_array($request->user()->id, config('editorial_workflow.legal_reviewers', []), true),
'legalReviewer' => $workflow->legalReviewer($request->user()),
'history' => DB::table('audit_events')->where('source_system', 'document-access-v1')->where('action', 'like', 'editorial_%')->where('after_data->resource_id', $id)->orderByDesc('id')->get(['id', 'action', 'actor_user_id', 'after_data', 'occurred_at']),
'editions' => DB::table('portal_editorial_editions')->where('resource_id', $id)->orderByDesc('revision_no')->get(['id', 'revision_no', 'state', 'content_hash', 'author_id', 'created_at'])];