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
@@ -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();