feat(editorial): preserve public editions and add QA review workflow

This commit is contained in:
2026-11-13 22:56:44 +02:00
parent 2f1ef2fdfc
commit b3849d850e
25 changed files with 1963 additions and 55 deletions
+3 -2
View File
@@ -7,6 +7,7 @@ use App\Models\Opportunity;
use App\Models\Sector;
use App\Services\DashboardWorkQueue;
use App\Services\InvestorPath;
use App\Services\PortalEditorialWorkflow;
use App\Services\PortalProfileAccess;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Schema;
@@ -28,7 +29,7 @@ class DashboardController extends Controller
$contexts = $path->contexts($request->user());
$current = $contexts ? $path->view($request->user(), $contexts[0]['id']) : null;
return Inertia::render('dashboard', ['summary' => null, 'workQueue' => $workQueue, 'investorPath' => $path->available() ? [
return Inertia::render('dashboard', ['editorialQueue' => app(PortalEditorialWorkflow::class)->dashboard($request->user()), 'summary' => null, 'workQueue' => $workQueue, 'investorPath' => $path->available() ? [
'contexts' => $contexts,
'current' => $current ? ['id' => $current['case']['id'], 'next' => $current['next'],
'pending' => count(array_filter($current['steps'], fn ($step) => ! in_array($step['state'], ['completed', 'approved', 'dispensed'], true))),
@@ -43,7 +44,7 @@ class DashboardController extends Controller
$drafts = app(PortalProfileAccess::class)->scope($drafts, $request->user());
}
return Inertia::render('dashboard', ['workQueue' => $workQueue, 'summary' => [
return Inertia::render('dashboard', ['editorialQueue' => app(PortalEditorialWorkflow::class)->dashboard($request->user()), 'workQueue' => $workQueue, 'summary' => [
'canCreateOpportunity' => app(PortalProfileAccess::class)->legacyWriteAllowed($request->user()),
'requestsByStatus' => collect(['received', 'in_review', 'contacted', 'closed'])
->mapWithKeys(fn ($status) => [$status => (int) ($counts[$status] ?? 0)]),
@@ -5,6 +5,7 @@ namespace App\Http\Controllers;
use App\Services\DocumentManagementAccess;
use App\Services\EditorialInstallation;
use App\Services\EditorialReading;
use App\Services\PortalEditorialWorkflow;
use App\Services\PortalProfileAccess;
use Illuminate\Contracts\Encryption\DecryptException;
use Illuminate\Database\Query\Builder;
@@ -97,11 +98,17 @@ class EditorialArticleController extends Controller
app(DocumentManagementAccess::class)->assertEditableResource(request()->user(), $id);
$r = DB::table('portal_resources')->where('id', $id)->first();
abort_unless($r instanceof \stdClass, 404);
$workflow = app(PortalEditorialWorkflow::class);
if ($workflow->enabled()) {
$workflow->authorize(request()->user(), $id);
$r = $workflow->workingResource($r);
}
$reading = app(EditorialReading::class);
$original = $reading->original($r);
$content = $reading->content($r, $original);
return tap(Inertia::render('portal/article-editor', ['resource' => ['id' => $r->id, 'title' => $r->title, 'metadata_version' => $r->metadata_version, 'published' => (bool) $r->published],
'workflowEnabled' => $workflow->enabled(), 'workflow' => $workflow->latest($id),
'content' => $content, 'source' => $original ? array_intersect_key($original, array_flip(['source_url', 'published_at', 'modified_at', 'legacy_id', 'image_source_url'])) : null, 'images' => $this->publicResources()->whereIn('d.media_type', ['image/png', 'image/jpeg', 'image/webp'])->get(['r.id', 'r.title']),
'revisions' => DB::table('portal_resource_revisions')->where('resource_id', $id)->orderByDesc('revision_no')->get(['revision_no', 'change_type', 'created_at'])])->toResponse(request()), static fn (Response $response) => $response->headers->set('Cache-Control', 'private, no-store'));
}
@@ -114,7 +121,9 @@ class EditorialArticleController extends Controller
$r = DB::table('portal_resources')->where('id', $id)->lockForUpdate()->first();
abort_unless($r !== null, 404);
app(DocumentManagementAccess::class)->assertEditableResource($request->user(), $id);
abort_unless((int) $r->metadata_version === (int) $data['metadata_version'], 409, 'O artigo foi alterado noutra sessão. Recarregue antes de editar.');
$workflow = app(PortalEditorialWorkflow::class);
$current = $workflow->enabled() ? $workflow->workingResource($r) : $r;
abort_unless((int) $current->metadata_version === (int) $data['metadata_version'], 409, 'O artigo foi alterado noutra sessão. Recarregue antes de editar.');
if (! empty($data['image_resource_id'])) {
abort_unless($this->publicResources()->where('r.id', $data['image_resource_id'])->whereIn('d.media_type', ['image/png', 'image/jpeg', 'image/webp'])->exists(), 422, 'Escolha uma imagem pública disponível na biblioteca.');
}
@@ -122,7 +131,13 @@ class EditorialArticleController extends Controller
if ($body === '') {
throw ValidationException::withMessages(['body' => 'Escreva o corpo do artigo.']);
}
$content = $this->edition($r, $data);
$workflow = app(PortalEditorialWorkflow::class);
$content = $this->edition($workflow->enabled() ? $workflow->workingResource($r) : $r, $data);
if ($workflow->enabled()) {
$workflow->save($request->user(), $id, (int) $data['metadata_version'], ['editorial_content' => json_encode($content, JSON_THROW_ON_ERROR)]);
return;
}
$revision = (int) $r->metadata_version + 1;
DB::table('portal_resources')->where('id', $id)->update(['editorial_content' => json_encode($content, JSON_THROW_ON_ERROR), 'published' => false, 'approved_by' => null, 'metadata_version' => $revision, 'last_reviewed_by' => $request->user()->id, 'last_reviewed_at' => now(), 'updated_at' => now()]);
$resource = DB::table('portal_resources')->where('id', $id)->first();
@@ -165,6 +180,11 @@ class EditorialArticleController extends Controller
app(DocumentManagementAccess::class)->assertEditableResource($request->user(), $id);
$resource = DB::table('portal_resources')->where('id', $id)->first();
abort_unless($resource instanceof \stdClass, 404);
$workflow = app(PortalEditorialWorkflow::class);
if ($workflow->enabled()) {
$workflow->authorize(request()->user(), $id);
$resource = $workflow->workingResource($resource);
}
return tap(Inertia::render('portal/article', ['article' => app(EditorialReading::class)->reading($resource, null, true)])
->toResponse($request), static function (Response $response): void {
@@ -179,6 +199,11 @@ class EditorialArticleController extends Controller
app(DocumentManagementAccess::class)->assertEditableResource($request->user(), $id);
$resource = DB::table('portal_resources')->where('id', $id)->first();
abort_unless($resource instanceof \stdClass, 404);
$workflow = app(PortalEditorialWorkflow::class);
if ($workflow->enabled()) {
$workflow->authorize($request->user(), $id);
$resource = $workflow->workingResource($resource);
}
$data = $this->validateContent($request);
abort_unless((int) $resource->metadata_version === (int) $data['metadata_version'], 409, 'A revisão mudou; recupere antes de rever a prévia.');
@@ -0,0 +1,118 @@
<?php
namespace App\Http\Controllers;
use App\Models\User;
use App\Services\ContentManagement;
use App\Services\DocumentManagementAccess;
use App\Services\PortalEditorialWorkflow;
use App\Services\VerifiedDocumentBinary;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Inertia\Inertia;
use Symfony\Component\HttpFoundation\Response;
final class PortalEditorialWorkflowController extends Controller
{
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 */
private function screen(Request $request, string $component, array $data): Response
{
$response = $request->expectsJson() ? response()->json($data) : Inertia::render($component, $data)->toResponse($request);
$response->headers->set('Cache-Control', 'private, no-store');
return $response;
}
public function index(Request $request, PortalEditorialWorkflow $workflow): Response
{
abort_unless($workflow->enabled(), 503);
$actor = $request->user();
$access = app(DocumentManagementAccess::class);
$query = $workflow->queue($actor);
$filters = array_filter($request->validate(['state' => 'nullable|in:draft,submitted,in_review,approved,published,returned,rejected,archived', 'kind' => 'nullable|in:article,legislation,document']), fn ($value) => $value !== null && $value !== '');
if (isset($filters['state'])) {
$query->where('e.state', $filters['state']);
}
if (isset($filters['kind'])) {
$query->whereRaw(self::KIND.' = ?', [$filters['kind']]);
}
$items = $query->orderByDesc('e.updated_at')->paginate(25, ['r.id', DB::raw("e.payload->'resource'->>'title' AS title"), 'r.document_type', 'r.published', 'e.id as edition_id', 'e.revision_no', 'e.state', 'e.legal_required', 'u.name as responsible'])->withQueryString();
$opportunities = app(ContentManagement::class)->access($actor)['opportunities'] ?? null;
$data = ['items' => $items, 'filters' => $filters, 'notices' => $workflow->notices($actor), 'resourceAccess' => $access->full($actor) || $access->editorial($actor), 'opportunitiesUrl' => $opportunities['index'] ?? null, 'opportunityPending' => $workflow->opportunityPending($actor)];
return $this->screen($request, 'management/editorial-workflow', $data);
}
public function show(Request $request, int $id, PortalEditorialWorkflow $workflow): Response
{
$workflow->authorize($request->user(), $id);
$resource = DB::table('portal_resources')->where('id', $id)->first();
abort_unless($resource !== null, 404);
$selected = $request->validate(['edition' => 'nullable|uuid'])['edition'] ?? null;
$latest = $workflow->latest($id);
$e = $selected ? DB::table('portal_editorial_editions')->where('resource_id', $id)->where('id', $selected)->first() : $latest;
abort_if($selected && ! $e, 404);
$payload = $e ? $workflow->payload($e) : null;
$independent = ! $e || ! $workflow->requiresIndependent($e) || ! in_array($request->user()->id, $payload['contributors'], true);
$data = ['resource' => ['id' => $id, 'title' => $resource->title, 'published' => (bool) $resource->published, 'public_edition_no' => $resource->published ? DB::table('portal_editorial_editions')->where('resource_id', $id)->where('state', 'published')->max('revision_no') : null], 'edition' => $e ? [...(array) $e, 'payload' => $payload, 'fresh' => $e->state === 'published' || $workflow->fresh($e)] : null,
'current' => $e && $e->id === $latest?->id,
'independentRequired' => $e ? $workflow->requiresIndependent($e) : false,
'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),
'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'])];
return $this->screen($request, 'management/editorial-edition', $data);
}
public function transition(Request $request, int $id, string $edition, PortalEditorialWorkflow $workflow): Response
{
$result = $workflow->transition($request->user(), $id, $edition, $request->all());
return $request->expectsJson() ? response()->json(['edition' => $result])->header('Cache-Control', 'private, no-store') : back()->with('success', 'Operação editorial registada.');
}
public function file(Request $request, int $id, string $edition, string $document, PortalEditorialWorkflow $workflow): Response
{
$workflow->authorize($request->user(), $id);
$e = DB::table('portal_editorial_editions')->where('id', $edition)->where('resource_id', $id)->first();
abort_unless($e !== null, 404);
$file = null;
foreach ($workflow->payload($e)['files'] as $entry) {
if ($entry['id'] === $document) {
$file = $entry;
break;
}
}
abort_unless($file !== null, 404);
abort_unless($workflow->canReadFile($request->user(), $document), 403);
$record = DB::table('documents')->where('id', $document)->first();
abort_unless($record && hash_equals(trim($file['sha256']), trim($record->sha256)) && (int) $file['version_no'] === (int) $record->version_no, 409, 'O anexo mudou; esta edição conserva a referência anterior.');
return $this->binary($record);
}
private function binary(\stdClass $record): Response
{
$response = app(VerifiedDocumentBinary::class)->respond($record);
$response->headers->set('Cache-Control', 'private, no-store');
$response->headers->set('X-Robots-Tag', 'noindex, nofollow');
return $response;
}
public function markRead(Request $request, int $notice, PortalEditorialWorkflow $workflow): Response
{
abort_unless($workflow->enabled(), 503);
$n = DB::table('portal_editorial_notifications')->where('id', $notice)->where('user_id', $request->user()->id)->first();
abort_unless($n !== null, 404);
abort_unless($workflow->visibleNotice($request->user(), $n), 404);
DB::table('portal_editorial_notifications')->where('id', $notice)->where('user_id', $request->user()->id)->whereNull('read_at')->update(['read_at' => now()]);
return $request->expectsJson() ? response()->json(['read' => true]) : back();
}
}
@@ -15,6 +15,7 @@ use App\Services\InterestSubmission;
use App\Services\InvestmentGeography;
use App\Services\LegacyContent;
use App\Services\Malware\MalwareScanner;
use App\Services\PortalEditorialWorkflow;
use App\Services\PortalProfileAccess;
use App\Services\PublicDocumentPresentation;
use App\Services\PublicSearchMetrics;
@@ -320,6 +321,9 @@ class PortalServicesController extends Controller
$editorial = $access->editorial($request->user());
$resources = $editorial ? $this->filterManagedResources($access->editorialResources($request->user()), $managementFilters)->orderByDesc('r.updated_at')
->get(['r.*', 'd.file_name', 'd.media_type', 'd.size_bytes', 'd.issuer', 'd.status as file_status', 'd.data_classification', 'd.version_no as file_version', 'd.rights_status as file_rights_status', 'd.malware_scan_status', 'd.malware_failure_reason']) : collect();
if (app(PortalEditorialWorkflow::class)->enabled()) {
$resources = $resources->map(fn ($r) => app(PortalEditorialWorkflow::class)->workingResource($r));
}
$links = DB::table('portal_resource_links')->whereIn('resource_id', $resources->pluck('id'))->orderBy('id')->get()->groupBy('resource_id');
$revisions = DB::table('portal_resource_revisions')->whereIn('resource_id', $resources->pluck('id'))->orderByDesc('revision_no')->get(['resource_id', 'revision_no', 'change_type', 'actor_user_id', 'created_at'])->groupBy('resource_id');
$resources->each(function ($resource) use ($links, $revisions) {
@@ -328,7 +332,7 @@ class PortalServicesController extends Controller
});
return Inertia::render('portal/manage', [
'managementFilters' => $managementFilters, 'associationOptions' => $this->contentAssociations($request),
'editorialWorkflowEnabled' => app(PortalEditorialWorkflow::class)->enabled(), 'managementFilters' => $managementFilters, 'associationOptions' => $this->contentAssociations($request),
'documentAccessOnly' => ! $editorial, 'editorialScopeOnly' => $editorial, 'resources' => $resources, 'partnerships' => [], 'territories' => [],
'requirementCatalogue' => [], 'requirementAssociationsEnabled' => false,
'disclosureAuthorization' => null, 'incompleteCount' => $resources->filter(fn ($r) => ! $r->description || ! $r->issuer || ! $r->source_url || $r->source_confirmation === 'unconfirmed' || $r->legal_status === 'unknown' || $r->rights_status === 'unconfirmed')->count(), 'possibleDuplicates' => $resources->whereNotNull('duplicate_of_resource_id')->count(),
@@ -353,12 +357,15 @@ class PortalServicesController extends Controller
$resources->each(fn ($resource) => $resource->links = $links->get($resource->id, collect())->values());
$revisions = DB::table('portal_resource_revisions')->whereIn('resource_id', $resources->pluck('id'))->orderByDesc('revision_no')->get(['resource_id', 'revision_no', 'change_type', 'actor_user_id', 'created_at'])->groupBy('resource_id');
$resources->each(fn ($resource) => $resource->revisions = $revisions->get($resource->id, collect())->values());
if (app(PortalEditorialWorkflow::class)->enabled()) {
$resources = $resources->map(fn ($r) => app(PortalEditorialWorkflow::class)->workingResource($r));
}
$malwareHealth = $scanner->health();
$malwareHealth['signatures_at'] = $malwareHealth['signatures_at']?->format(DATE_ATOM);
return Inertia::render('portal/manage', ['resources' => $resources,
'managementFilters' => $managementFilters, 'associationOptions' => $this->contentAssociations($request),
'editorialWorkflowEnabled' => app(PortalEditorialWorkflow::class)->enabled(), 'managementFilters' => $managementFilters, 'associationOptions' => $this->contentAssociations($request),
'requirementCatalogue' => $this->requirementEvidenceEnabled()
? collect(is_array($catalogue = config('document-requirements.catalogue', [])) ? $catalogue : [])->map(fn ($item, $key) => ['key' => $key, ...$item])->values()
: collect(),
@@ -474,9 +481,15 @@ class PortalServicesController extends Controller
$duplicate = $identity ? DB::table('portal_resources')->where('document_identity_key', $identity)->value('id') : null;
$fields = $this->resourceFields($data, $request->user()->id, $identity, $duplicate, true);
$this->assertPublicClassificationEligible($data, $document);
$workflow = app(PortalEditorialWorkflow::class);
$id = DB::table('portal_resources')->insertGetId([...$fields, 'approved_by' => null, 'published' => false, 'created_at' => now(), 'updated_at' => now()]);
$this->syncResourceLinks($id, $data['associations'] ?? []);
$this->recordResourceRevision($id, 'created', $request->user()->id, 1);
if ($workflow->enabled()) {
$workflow->save($request->user(), $id, 1, ['_document' => ['issuer' => ($data['issuer'] ?? null) ?: $document->issuer, 'issued_on' => ($data['document_date'] ?? null) ?: $document->issued_on, 'rights_status' => $data['rights_status'], 'data_classification' => $data['data_classification'] ?? $document->data_classification]]);
return $id;
}
DB::table('documents')->where('id', $document->id)->update(['issuer' => ($data['issuer'] ?? null) ?: $document->issuer,
'issued_on' => ($data['document_date'] ?? null) ?: $document->issued_on, 'rights_status' => $data['rights_status'],
'data_classification' => $data['data_classification'] ?? $document->data_classification, 'updated_at' => now()]);
@@ -496,7 +509,9 @@ class PortalServicesController extends Controller
$resource = DB::table('portal_resources')->where('id', $id)->lockForUpdate()->first();
abort_unless($resource !== null, 404);
app(DocumentManagementAccess::class)->assertEditableResource($request->user(), $id);
abort_if((int) $resource->metadata_version !== $expectedVersion, 409, 'O documento foi alterado por outra sessão. Recarregue a revisão antes de editar.');
$workflow = app(PortalEditorialWorkflow::class);
$current = $workflow->enabled() ? $workflow->workingResource($resource) : $resource;
abort_if((int) $current->metadata_version !== $expectedVersion, 409, 'O documento foi alterado por outra sessão. Recarregue a revisão antes de editar.');
$document = DB::table('documents')->where('id', $data['document_id'])->where('status', '!=', 'deleted')->whereNull('deleted_at')->lockForUpdate()->first();
abort_unless($document !== null, 422, 'Seleccione um documento existente e não eliminado.');
abort_unless(app(DocumentManagementAccess::class)->documents($request->user())->where('id', $document->id)->exists(), 403);
@@ -507,6 +522,12 @@ class PortalServicesController extends Controller
$duplicate = $identity ? DB::table('portal_resources')->where('document_identity_key', $identity)->where('id', '!=', $id)->value('id') : null;
$fields = $this->resourceFields($data, $request->user()->id, $identity, $duplicate, false);
$this->assertPublicClassificationEligible($data, $document);
$workflow = app(PortalEditorialWorkflow::class);
if ($workflow->enabled()) {
$workflow->save($request->user(), $id, $expectedVersion, [...$fields, '_document' => ['issuer' => ($data['issuer'] ?? null) ?: $document->issuer, 'issued_on' => ($data['document_date'] ?? null) ?: $document->issued_on, 'rights_status' => $data['rights_status'], 'data_classification' => $data['data_classification'] ?? $document->data_classification]], $data['associations'] ?? []);
return;
}
$retainPublication = (bool) $resource->published && $data['rights_status'] === 'authorized'
&& $data['source_confirmation'] !== 'unconfirmed' && ! empty($data['source_url'])
&& (! ($data['legislation'] || $data['document_type'] === 'legislation') || $data['source_confirmation'] === 'official_confirmed')
@@ -523,7 +544,7 @@ class PortalServicesController extends Controller
$this->audit($request, 'resource_metadata_updated', 'portal_resources', $id);
});
return to_route('services.manage')->with('success', 'Metadados e relações actualizados; o estado de publicação foi preservado.');
return to_route('services.manage')->with('success', 'Edição guardada; confira o circuito editorial quando estiver activo. A versão pública é preservada nesse circuito.');
}
public function moderate(Request $request, string $type, int $id): RedirectResponse
@@ -537,6 +558,12 @@ class PortalServicesController extends Controller
return redirect('/parcerias/'.$id)->with('success', 'Reveja a proposta e registe uma decisão fundamentada na ficha da Bolsa.');
}
$data = $this->validateManagement($request, ['published' => ['required', 'boolean']]);
if ($type === 'resources' && app(PortalEditorialWorkflow::class)->enabled()) {
app(PortalEditorialWorkflow::class)->authorize($request->user(), $id, true);
}
if ($type === 'resources' && $data['published'] && app(PortalEditorialWorkflow::class)->enabled()) {
abort(409, 'Use a aprovação e publicação explícitas da edição no circuito editorial.');
}
$table = $type === 'resources' ? 'portal_resources' : 'portal_partnerships';
DB::transaction(function () use ($request, $data, $table, $type, $id) {
abort_unless(DB::table($table)->where('id', $id)->lockForUpdate()->first() !== null, 404);
@@ -582,6 +609,7 @@ class PortalServicesController extends Controller
public function applyDocumentDisclosureAuthorization(Request $request): RedirectResponse
{
abort_if(app(PortalEditorialWorkflow::class)->enabled(), 409, 'A divulgação de novas edições requer aprovação e publicação no circuito editorial.');
$event = DB::table('audit_events')->where('source_system', 'user-instruction')
->where('external_id', 'document-repository-disclosure-authorization-2026-10-06-v1')
->where('action', 'public_disclosure_authorized')->first();