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();
+4
View File
@@ -35,6 +35,10 @@ final class ContentManagement
$result['documents'] = ['index' => '/management/portal-services', 'edit' => $editor];
}
if (($editor || isset($result['opportunities'])) && ! $profiles->has($actor, 'auditor') && app(PortalEditorialWorkflow::class)->available()) {
$result['editorial'] = ['index' => '/management/editorial'];
}
return $result;
}
@@ -308,6 +308,9 @@ class OpportunityCanonicalContract
app(OpportunityLibrarySupport::class)->assertAvailable($row, $file);
app(VerifiedDocumentBinary::class)->respond($file);
$row->sha256 = trim($file->sha256);
if (data_get($record->workflow_context, 'editorial_version_binding') === 'phase1-exact-v1') {
$row->file_version_no = (int) $file->version_no;
}
}
} else {
$location = $row->payload;
+9 -2
View File
@@ -178,6 +178,11 @@ class OpportunityProfileWorkflow
abort_unless(hash_equals($this->management->etag($record), $data['etag']), 409, 'Versão alterada.');
$this->authorizeAction($actor, $record, $action);
$context = $record->workflow_context ?? [];
if ($action === 'submit' && app(PortalEditorialWorkflow::class)->available()) {
$context['editorial_version_binding'] = 'phase1-exact-v1';
$record->workflow_context = $context;
}
$strictEditorial = ($context['editorial_version_binding'] ?? null) === 'phase1-exact-v1';
$state = $this->access->state($record);
$before = ['status' => $state, 'publication_status' => $record->status, 'version' => $record->management_version];
$from = match ($action) {
@@ -234,7 +239,7 @@ class OpportunityProfileWorkflow
}
}
$baseline = $context['review_baseline'] ?? $context['submission'] ?? [];
$materiality = app(OpportunityReviewMateriality::class)->reconcile($baseline, $snapshot, $context['reviews'] ?? [], $this->contract->gates($record), ! empty($context['force_review']));
$materiality = app(OpportunityReviewMateriality::class)->reconcile($baseline, $snapshot, $context['reviews'] ?? [], $this->contract->gates($record), ! empty($context['force_review']), $strictEditorial);
$context['materiality'] = array_diff_key($materiality, ['reviews' => true]);
$context['reviews'] = $materiality['reviews'];
app(OpportunityReviewConditions::class)->reconcile($context, $snapshot, $materiality['reopened']);
@@ -290,7 +295,7 @@ class OpportunityProfileWorkflow
foreach ($this->contract->gates($record) as $gate) {
$review = $context['reviews'][$gate] ?? [];
abort_unless(($review['decision'] ?? null) === 'approved' && (($review['hash'] ?? '') === $snapshot['hash'] || ($review['accepted_submission_hash'] ?? '') === $snapshot['hash']), 422, 'Gate '.$gate.' pendente, rejeitado ou de outra versão.');
abort_unless(app(OpportunityReviewMateriality::class)->matches($snapshot, $gate, $review), 409, 'Âmbito da revisão alterado.');
abort_unless(app(OpportunityReviewMateriality::class)->matches($snapshot, $gate, $review, $strictEditorial), 409, 'Âmbito ou versão da revisão alterado.');
$reviewer = User::whereKey($review['actor_id'])->firstOrFail();
abort_unless($this->access->scoped($reviewer, 'validator', $record->organization_id, $record->opportunity_category)
&& ! $this->access->conflict($reviewer, $record) && $this->access->operational($reviewer, 'validate', $record->id)
@@ -316,6 +321,7 @@ class OpportunityProfileWorkflow
foreach ($this->contract->gates($record) as $gate) {
$review = $context['approval']['gates'][$gate] ?? [];
abort_unless(($review['decision'] ?? null) === 'approved' && (($review['hash'] ?? '') === $snapshot['hash'] || ($review['accepted_submission_hash'] ?? '') === $snapshot['hash']), 422, 'Revisão aprovada desta versão necessária.');
abort_unless(app(OpportunityReviewMateriality::class)->matches($snapshot, $gate, $review, $strictEditorial), 409, 'Âmbito ou versão jurídica alterados após a aprovação.');
$reviewer = User::whereKey($review['actor_id'])->firstOrFail();
abort_unless($this->access->scoped($reviewer, 'validator', $record->organization_id, $record->opportunity_category)
&& ! $this->access->conflict($reviewer, $record) && $this->access->operational($reviewer, 'validate', $record->id)
@@ -476,6 +482,7 @@ class OpportunityProfileWorkflow
$record->management_version++;
$record->workflow_context = $context;
$record->save();
app(PortalEditorialWorkflow::class)->notifyOpportunity($actor, $record, $action);
$this->access->audit($actor, 'workflow_'.$action, ['record_id' => $record->id, 'before' => $before,
'after' => ['status' => $next, 'publication_status' => $record->status, 'version' => $record->management_version], 'reason' => $data['reason']]);
@@ -134,8 +134,12 @@ class OpportunityReviewMateriality
/** @param array<string,mixed> $snapshot
* @param array<string,mixed> $review */
public function matches(array $snapshot, string $gate, array $review): bool
public function matches(array $snapshot, string $gate, array $review, bool $exactLegalVersion = false): bool
{
if ($exactLegalVersion && preg_match('/legal|registry|authority|confidentiality|license/', $gate) === 1
&& ($review['hash'] ?? null) !== ($snapshot['hash'] ?? null)) {
return false;
}
if (! isset($review['scope_hash'])) {
return true; // Caller must independently verify the original signed submission hash.
}
@@ -151,7 +155,7 @@ class OpportunityReviewMateriality
* @param array<string,mixed> $reviews
* @param list<string> $gates
* @return array<string,mixed> */
public function reconcile(array $before, array $after, array $reviews, array $gates, bool $force = false): array
public function reconcile(array $before, array $after, array $reviews, array $gates, bool $force = false, bool $exactLegalVersion = false): array
{
$kept = [];
$reopened = [];
@@ -161,7 +165,8 @@ class OpportunityReviewMateriality
// separately validate old scope signatures under their original rule.
$priorHash = $this->hash($before, $gate, $review);
$currentHash = $this->hash($after, $gate, $review);
if (! $force && $before !== [] && ($review['decision'] ?? '') === 'approved' && $this->matches($before, $gate, $review) && hash_equals($priorHash, $currentHash)) {
$changedLegalVersion = $exactLegalVersion && preg_match('/legal|registry|authority|confidentiality|license/', $gate) === 1 && ($review['hash'] ?? null) !== ($after['hash'] ?? null);
if (! $force && ! $changedLegalVersion && $before !== [] && ($review['decision'] ?? '') === 'approved' && $this->matches($before, $gate, $review) && hash_equals($priorHash, $currentHash)) {
$kept[$gate] = [...$review, 'scope_hash' => $priorHash, 'scope_rule' => self::VERSION, 'accepted_submission_hash' => $after['hash'],
'carried_forward' => ['rule' => self::VERSION, 'from_scope_hash' => $review['scope_hash'] ?? null, 'from_scope_rule' => $review['scope_rule'] ?? 'legacy_unspecified', 'from_submission_hash' => $before['hash'] ?? null, 'at' => now()->toIso8601String()]];
} else {
+444
View File
@@ -0,0 +1,444 @@
<?php
namespace App\Services;
use App\Models\Opportunity;
use App\Models\User;
use Illuminate\Database\Query\Builder;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str;
use Symfony\Component\HttpKernel\Exception\HttpException;
/** Private immutable content editions; existing resources remain the public projection.
* @phpstan-type EditorialPayload array{resource:array<string,mixed>,links:list<array<string,mixed>>,files:list<array<string,mixed>>,document_changes:array<string,mixed>,contributors:list<int>,policy:array{independent_required:bool}}
*/
final class PortalEditorialWorkflow
{
public function available(): bool
{
return (bool) config('editorial_workflow.enabled') && Schema::hasTable('portal_editorial_editions') && Schema::hasColumn('portal_editorial_notifications', 'opportunity_id');
}
public function enabled(): bool
{
if (! config('editorial_workflow.enabled')) {
return false;
}
abort_unless($this->available(), 503, 'Estrutura editorial ainda não instalada.');
return true;
}
public function authorize(User $actor, int $resource, bool $write = false): void
{
abort_unless($this->enabled(), 503, 'Circuito editorial ainda não instalado/activado neste ambiente.');
abort_unless($actor->hasVerifiedEmail() && ! app(PortalProfileAccess::class)->has($actor, 'auditor'), 403);
app(DocumentManagementAccess::class)->assertEditableResource($actor, $resource);
if ($edition = $this->latest($resource)) {
foreach ($this->payload($edition)['files'] as $file) {
abort_unless($this->canReadFile($actor, $file['id']), 403);
}
}
if ($write) {
abort_unless(app(PortalProfileAccess::class)->mfa($actor), 403, 'Confirme o MFA na sessão existente.');
}
}
public function canReadFile(User $actor, string $id): bool
{
return app(DocumentManagementAccess::class)->full($actor) || app(DocumentManagementAccess::class)->documents($actor)->where('id', $id)->exists()
|| app(EditorialReading::class)->publicResources()->where('d.id', $id)->exists();
}
public function fresh(\stdClass $e): bool
{
$p = $this->payload($e);
try {
return hash_equals($e->content_hash, $this->hash($this->snapshot($p['resource'], $p['links'], $p['document_changes'], $p['contributors'], $p['policy'])));
} catch (HttpException $exception) {
if (in_array($exception->getStatusCode(), [404, 422], true)) {
return false;
}
throw $exception;
}
}
public function latest(int $resource): ?\stdClass
{
return $this->enabled() ? DB::table('portal_editorial_editions')->where('resource_id', $resource)->orderByDesc('revision_no')->first() : null;
}
/** @return EditorialPayload */
public function payload(\stdClass $edition): array
{
return json_decode($edition->payload, true, 512, JSON_THROW_ON_ERROR);
}
public function workingResource(\stdClass $resource): \stdClass
{
$e = $this->latest((int) $resource->id);
return $e ? (object) [...(array) $resource, ...$this->payload($e)['resource'], 'metadata_version' => max((int) $resource->metadata_version, (int) $e->revision_no), 'published' => $resource->published] : $resource;
}
public function queue(User $actor): Builder
{
abort_unless($this->enabled(), 503);
$access = app(DocumentManagementAccess::class);
abort_unless($this->canEnter($actor), 403);
$query = DB::table('portal_editorial_editions as e')->join('portal_resources as r', 'r.id', '=', 'e.resource_id')->leftJoin('users as u', 'u.id', '=', 'e.assigned_to')
->whereNotExists(fn ($q) => $q->selectRaw('1')->from('portal_editorial_editions as later')->whereColumn('later.resource_id', 'e.resource_id')->whereColumn('later.revision_no', '>', 'e.revision_no'));
if (! ($access->full($actor) || $access->editorial($actor))) {
$query->whereRaw('false');
} elseif (! $access->full($actor)) {
$query->whereIn('r.id', $access->editorialResources($actor)->select('r.id'));
}
return $query;
}
public function canEnter(User $actor): bool
{
if (! $actor->hasVerifiedEmail() || app(PortalProfileAccess::class)->has($actor, 'auditor')) {
return false;
}
$access = app(DocumentManagementAccess::class);
if ($access->full($actor) || $access->editorial($actor)) {
return true;
}
$navigation = app(PortalProfileAccess::class)->navigation($actor);
return ! empty($navigation['mine']) || ! empty($navigation['review']);
}
public function opportunityPending(User $actor): int
{
$profiles = app(PortalProfileAccess::class);
if (! $profiles->available()) {
return 0;
}
return $profiles->scope(Opportunity::query(), $actor)->whereRaw("COALESCE(workflow_context->>'working_state',status) IN ('draft','submitted','in_review','information_pending','needs_information','approved')")->count();
}
public function notifyOpportunity(User $actor, Opportunity $record, string $action): void
{
if (! $this->available()) {
return;
}
$context = $record->workflow_context ?? [];
$recipients = array_unique(array_filter([$record->owner_user_id, $context['assignment']['assignee_user_id'] ?? null, $context['publication_assignment']['assignee_user_id'] ?? null]));
foreach ($recipients as $recipient) {
if ((int) $recipient === $actor->id) {
continue;
}
DB::table('portal_editorial_notifications')->insertOrIgnore(['user_id' => $recipient, 'edition_id' => null, 'opportunity_id' => $record->id, 'opportunity_version' => $record->management_version,
'content_hash' => $context['submission']['hash'] ?? $context['approval']['hash'] ?? null, 'action' => $action, 'created_at' => now()]);
}
}
public function visibleNotice(User $actor, \stdClass $notice): bool
{
if ((int) $notice->user_id !== $actor->id || ! $this->available() || ! $this->canEnter($actor)) {
return false;
}
if ($notice->opportunity_id !== null) {
$record = Opportunity::query()->whereKey($notice->opportunity_id)->first();
return $record !== null && app(PortalProfileAccess::class)->canView($actor, $record);
}
$edition = DB::table('portal_editorial_editions')->where('id', $notice->edition_id)->first();
if (! $edition) {
return false;
}
try {
$this->authorize($actor, (int) $edition->resource_id);
return true;
} catch (HttpException $exception) {
if (in_array($exception->getStatusCode(), [403, 404], true)) {
return false;
} throw $exception;
}
}
/** @param array<string,mixed> $resource */
private function independentPolicy(int $id, array $resource): bool
{
$kind = ($resource['legislation'] ?? false) || ($resource['document_type'] ?? '') === 'legislation' ? 'legislation' : (! empty($resource['editorial_content']) ? 'article' : 'document');
return in_array($id, config('editorial_workflow.independent_approval.resources', []), true) || in_array($kind, config('editorial_workflow.independent_approval.kinds', []), true);
}
public function requiresIndependent(\stdClass $edition): bool
{
$p = $this->payload($edition);
return $p['policy']['independent_required'] || $this->independentPolicy((int) $edition->resource_id, $p['resource']);
}
/** @return list<array<string,mixed>> */
public function notices(User $actor): array
{
if (! $this->available()) {
return [];
}
abort_unless($this->canEnter($actor), 403);
$result = [];
foreach (DB::table('portal_editorial_notifications')->where('user_id', $actor->id)->lazyByIdDesc(100) as $notice) {
if (! $this->visibleNotice($actor, $notice)) {
continue;
}
$edition = $notice->edition_id ? DB::table('portal_editorial_editions')->where('id', $notice->edition_id)->first() : null;
$result[] = ['id' => $notice->id, 'action' => $notice->action, 'read_at' => $notice->read_at,
'href' => $notice->opportunity_id !== null ? '/minhas-oportunidades/'.$notice->opportunity_id : '/management/editorial/resources/'.$edition->resource_id.'?edition='.$notice->edition_id, 'reference' => $notice->opportunity_id !== null ? 'Revisão '.$notice->opportunity_version : 'Edição '.$edition->revision_no];
if (count($result) === 50) {
break;
}
}
return $result;
}
public function unreadCount(User $actor): int
{
$count = 0;
foreach (DB::table('portal_editorial_notifications')->where('user_id', $actor->id)->whereNull('read_at')->lazyByIdDesc(100) as $notice) {
if ($this->visibleNotice($actor, $notice)) {
$count++;
}
}
return $count;
}
/** @return array<string,int>|null */
public function dashboard(User $actor): ?array
{
$access = app(DocumentManagementAccess::class);
if (! $this->available() || ! $this->canEnter($actor)) {
return null;
}
return ['pending' => $this->queue($actor)->whereNotIn('e.state', ['published', 'archived'])->count() + $this->opportunityPending($actor), 'unread' => $this->unreadCount($actor)];
}
/** @param array<string,mixed> $resource
* @param list<array<string,mixed>> $links
* @param array<string,mixed> $documentChanges
* @param list<int> $contributors
* @param array{independent_required:bool} $policy
* @return EditorialPayload
*/
private function snapshot(array $resource, array $links, array $documentChanges = [], array $contributors = [], array $policy = ['independent_required' => false]): array
{
$ids = [(string) $resource['document_id']];
$body = is_string($resource['editorial_content'] ?? null) ? json_decode($resource['editorial_content'], true, 512, JSON_THROW_ON_ERROR) : ($resource['editorial_content'] ?? null);
if (! empty($body['image_resource_id'])) {
$image = DB::table('portal_resources')->where('id', $body['image_resource_id'])->first();
abort_unless($image && app(EditorialReading::class)->publicResources()->where('r.id', $image->id)->exists(), 422, 'Imagem pública indisponível.');
$ids[] = (string) $image->document_id;
}
foreach ($links as $link) {
if ($link['target_type'] === 'document') {
$id = DB::table('portal_resources')->where('id', $link['target_key'])->value('document_id');
abort_unless($id !== null, 422, 'Anexo documental inexistente.');
$ids[] = (string) $id;
}
}
$files = array_values(DB::table('documents')->whereIn('id', array_unique($ids))->orderBy('id')->get()->map(function ($document) {
if ($document->status === 'active' && $document->malware_scan_status === 'clean' && $document->deleted_at === null) {
app(VerifiedDocumentBinary::class)->respond($document);
}
return Arr::only(get_object_vars($document), ['id', 'file_name', 'media_type', 'size_bytes', 'sha256', 'version_no', 'status', 'deleted_at', 'issuer', 'issued_on', 'rights_status', 'data_classification', 'malware_scan_status', 'malware_scanned_sha256']);
})->all());
abort_unless(count($files) === count(array_unique($ids)), 422, 'Documento não encontrado.');
return ['resource' => $resource, 'links' => $links, 'files' => $files, 'document_changes' => $documentChanges, 'contributors' => $contributors, 'policy' => $policy];
}
/** @param array<string,mixed> $payload */
private function hash(array $payload): string
{
return hash('sha256', json_encode(Arr::sortRecursive($payload), JSON_THROW_ON_ERROR));
}
/** @param array<string,mixed> $changes
* @param list<array<string,mixed>>|null $links
*/
public function save(User $actor, int $resource, int $expectedVersion, array $changes, ?array $links = null): \stdClass
{
$this->authorize($actor, $resource, true);
return DB::transaction(function () use ($actor, $resource, $expectedVersion, $changes, $links) {
$r = DB::table('portal_resources')->where('id', $resource)->lockForUpdate()->first();
abort_unless($r !== null, 404);
abort_unless((int) $this->workingResource($r)->metadata_version === $expectedVersion, 409, 'A edição mudou. Recupere a versão actual antes de guardar.');
$previous = $this->latest($resource);
if (! $previous && $r->published) {
$public = $this->snapshot((array) $r, array_values(DB::table('portal_resource_links')->where('resource_id', $resource)->orderBy('id')->get()->map(fn ($l) => get_object_vars($l))->all()));
DB::table('portal_editorial_editions')->insert(['id' => (string) Str::uuid(), 'resource_id' => $resource, 'revision_no' => $expectedVersion,
'state' => 'published', 'version' => 1, 'payload' => json_encode($public, JSON_THROW_ON_ERROR), 'content_hash' => $this->hash($public),
'author_id' => $r->created_by ?? $actor->id, 'legal_required' => false, 'created_at' => now(), 'updated_at' => now()]);
}
$base = $previous ? $this->payload($previous) : ['resource' => (array) $r, 'links' => array_values(DB::table('portal_resource_links')->where('resource_id', $resource)->orderBy('id')->get()->map(fn ($l) => get_object_vars($l))->all())];
$fields = array_replace($base['resource'], Arr::except($changes, ['_document', 'id', 'created_by', 'approved_by', 'published', 'metadata_version', 'created_at', 'updated_at', 'independent_review']));
$fields['metadata_version'] = $expectedVersion + 1;
$fields['independent_review'] = false;
$contributors = array_values(array_unique([...($previous && $previous->state !== 'published' ? ($base['contributors'] ?? [$previous->author_id]) : []), $actor->id]));
$snapshot = $this->snapshot($fields, $links ?? $base['links'], Arr::only($changes['_document'] ?? $base['document_changes'] ?? [], ['issuer', 'issued_on', 'rights_status', 'data_classification']), $contributors, ['independent_required' => $this->independentPolicy($resource, $fields)]);
foreach ($snapshot['files'] as $file) {
abort_unless($this->canReadFile($actor, $file['id']), 403);
}
$id = (string) Str::uuid();
DB::table('portal_editorial_editions')->insert(['id' => $id, 'resource_id' => $resource, 'revision_no' => $expectedVersion + 1,
'state' => 'draft', 'version' => 1, 'payload' => json_encode($snapshot, JSON_THROW_ON_ERROR), 'content_hash' => $this->hash($snapshot),
'author_id' => $actor->id, 'legal_required' => (bool) ($r->legislation || ($previous->legal_required ?? false) || ($fields['legislation'] ?? false)) || ($fields['document_type'] ?? '') === 'legislation', 'created_at' => now(), 'updated_at' => now()]);
$edition = DB::table('portal_editorial_editions')->where('id', $id)->first();
$this->audit($actor, $edition, 'draft_saved', 'Edição privada guardada; versão pública preservada.');
return $edition;
});
}
/** @return array<string,mixed> */
private function decision(User $actor, \stdClass $edition, string $reason): array
{
return ['actor_id' => $actor->id, 'edition_id' => $edition->id, 'hash' => $edition->content_hash, 'reason' => $reason, 'at' => now()->toISOString()];
}
/** @param array<string,mixed> $input */
public function transition(User $actor, int $resource, string $id, array $input): \stdClass
{
$this->authorize($actor, $resource, true);
$data = validator($input, ['version' => 'required|integer|min:1', 'action' => 'required|in:submit,start_review,return,reject,legal_validate,approve,publish,archive', 'reason' => 'required|string|min:3|max:5000', 'assigned_to' => 'nullable|integer|exists:users,id'])->validate();
return DB::transaction(function () use ($actor, $resource, $id, $data) {
$r = DB::table('portal_resources')->where('id', $resource)->lockForUpdate()->first();
$e = DB::table('portal_editorial_editions')->where('id', $id)->where('resource_id', $resource)->lockForUpdate()->first();
abort_unless($r && $e, 404);
abort_unless((int) $e->version === (int) $data['version'] && $this->latest($resource)?->id === $id, 409, 'Versão desactualizada.');
$action = $data['action'];
if (! in_array($action, ['submit', 'archive'], true)) {
abort_unless(app(DocumentManagementAccess::class)->full($actor), 403, 'Responsável de revisão/publicação necessário.');
}
if ($action === 'archive') {
abort_unless((int) $e->author_id === $actor->id || app(DocumentManagementAccess::class)->full($actor), 403);
}
$allowed = match ($action) {
'submit' => ['draft', 'returned'], 'start_review' => ['submitted'], 'return', 'reject', 'legal_validate', 'approve' => ['in_review'], 'publish' => ['approved'], 'archive' => ['draft', 'submitted', 'in_review', 'returned', 'rejected', 'approved', 'published'],
default => abort(422, 'Operação editorial desconhecida.'),
};
abort_unless(in_array($e->state, $allowed, true), 409, 'Transição incompatível com o estado actual.');
$payload = $this->payload($e);
// Content is immutable; changed files invalidate the decision rather than being silently accepted.
if ($action !== 'archive') {
abort_unless(hash_equals($e->content_hash, $this->hash($this->snapshot($payload['resource'], $payload['links'], $payload['document_changes'], $payload['contributors'], $payload['policy']))), 409, 'Conteúdo ou anexos alterados. Guarde uma nova edição e obtenha nova aprovação.');
}
$updates = [];
if (in_array($action, ['approve', 'legal_validate'], true) && $this->requiresIndependent($e)) {
abort_if(in_array($actor->id, $payload['contributors'], true), 403, 'Esta edição exige revisão independente.');
}
if (in_array($action, ['approve', 'legal_validate'], true)) {
foreach ($payload['files'] as $file) {
abort_unless($file['status'] === 'active' && $file['deleted_at'] === null && $file['malware_scan_status'] === 'clean' && hash_equals(trim($file['sha256']), trim((string) $file['malware_scanned_sha256'])), 422, 'Conclua a análise dos anexos antes da aprovação.');
}
}
if ($action === 'legal_validate') {
abort_unless(in_array($actor->id, config('editorial_workflow.legal_reviewers', []), true), 403, 'Designação jurídica explícita necessária.');
$updates['legal_validation'] = json_encode($this->decision($actor, $e, $data['reason']), JSON_THROW_ON_ERROR);
}
if ($action === 'approve') {
if ($e->legal_required) {
$this->assertDecision($e, $e->legal_validation, 'Validação jurídica desta edição necessária.', true);
}
$updates['approval'] = json_encode($this->decision($actor, $e, $data['reason']), JSON_THROW_ON_ERROR);
}
if ($action === 'publish') {
$this->assertDecision($e, $e->approval, 'Aprovação desta edição necessária.');
if ($e->legal_required) {
$this->assertDecision($e, $e->legal_validation, 'Validação jurídica desta edição necessária.', true);
}
$this->publish($actor, $r, $e, $payload);
$updates['published_at'] = now();
}
if ($action === 'submit' && isset($data['assigned_to'])) {
$responsible = User::query()->whereKey($data['assigned_to'])->firstOrFail();
abort_unless(app(DocumentManagementAccess::class)->full($responsible) && ! app(PortalProfileAccess::class)->has($responsible, 'auditor'), 422, 'Responsável sem autoridade editorial.');
$updates['assigned_to'] = $responsible->id;
}
if ($action === 'archive' && $e->state === 'published') {
DB::table('portal_resources')->where('id', $resource)->update(['published' => false, 'metadata_version' => (int) $r->metadata_version + 1, 'updated_at' => now()]);
}
$updates['state'] = match ($action) {
'submit' => 'submitted', 'start_review', 'legal_validate' => 'in_review', 'return' => 'returned', 'reject' => 'rejected', 'approve' => 'approved', 'publish' => 'published', 'archive' => 'archived',
};
$updates['version'] = (int) $e->version + 1;
$updates['updated_at'] = now();
DB::table('portal_editorial_editions')->where('id', $id)->update($updates);
$this->audit($actor, $e, $action, $data['reason'], $updates['state']);
foreach (array_unique(array_filter([$e->author_id, $updates['assigned_to'] ?? $e->assigned_to])) as $recipient) {
if ((int) $recipient !== $actor->id) {
DB::table('portal_editorial_notifications')->insert(['edition_id' => $id, 'user_id' => $recipient, 'action' => $action, 'created_at' => now()]);
}
}
return DB::table('portal_editorial_editions')->where('id', $id)->first();
});
}
private function assertDecision(\stdClass $e, ?string $decision, string $message, bool $legal = false): void
{
$d = $decision ? json_decode($decision, true, 512, JSON_THROW_ON_ERROR) : [];
abort_unless(($d['edition_id'] ?? null) === $e->id && ($d['hash'] ?? null) === $e->content_hash, 422, $message);
$actor = User::query()->whereKey($d['actor_id'] ?? 0)->first();
abort_unless($actor && $actor->hasVerifiedEmail() && app(DocumentManagementAccess::class)->full($actor) && ! app(PortalProfileAccess::class)->has($actor, 'auditor'), 403, 'Autoridade da decisão revogada.');
if ($legal) {
abort_unless(in_array($actor->id, config('editorial_workflow.legal_reviewers', []), true), 403, 'Designação jurídica revogada.');
}
if ($this->requiresIndependent($e)) {
abort_if(in_array($actor->id, $this->payload($e)['contributors'], true), 403, 'A decisão exige aprovação independente.');
}
}
/** @param array<string,mixed> $payload */
private function publish(User $actor, \stdClass $r, \stdClass $e, array $payload): void
{
$fields = $payload['resource'];
foreach ($payload['files'] as $file) {
if ($file['id'] === $fields['document_id']) {
$file = array_replace($file, $payload['document_changes']);
}
abort_unless($file['status'] === 'active' && $file['deleted_at'] === null && $file['rights_status'] === 'authorized' && $file['data_classification'] === 'public'
&& $file['malware_scan_status'] === 'clean' && hash_equals(trim($file['sha256']), trim((string) $file['malware_scanned_sha256'])), 422, 'Anexos públicos, autorizados e verificados necessários.');
}
abort_unless(($fields['rights_status'] ?? '') === 'authorized' && ($fields['source_confirmation'] ?? 'unconfirmed') !== 'unconfirmed' && ! empty($fields['source_url']), 422, 'Direitos e fonte necessários.');
abort_unless(! (($fields['legislation'] ?? false) || ($fields['document_type'] ?? '') === 'legislation') || ($fields['source_confirmation'] ?? '') === 'official_confirmed', 422, 'Confirmação da fonte legislativa necessária.');
$changes = $payload['document_changes'];
if ($changes !== []) {
$document = DB::table('documents')->where('id', $fields['document_id'])->lockForUpdate()->first();
$shared = DB::table('portal_resources')->where('document_id', $document->id)->where('id', '!=', $r->id)->where('published', true)->exists();
foreach ($changes as $key => $value) {
abort_if($shared && $value != $document->$key, 422, 'Crie uma nova versão do ficheiro para alterar metadados partilhados com outra publicação.');
}
DB::table('documents')->where('id', $document->id)->update([...$changes, 'updated_at' => now()]);
}
$fields = Arr::except($fields, ['id', 'metadata_version', 'created_at', 'created_by', 'updated_at', 'published', 'approved_by', 'independent_review']);
$fields['independent_review'] = ! in_array(json_decode($e->approval, true, 512, JSON_THROW_ON_ERROR)['actor_id'], $payload['contributors'], true);
DB::table('portal_resources')->where('id', $r->id)->update([...$fields, 'published' => true, 'metadata_version' => max((int) $r->metadata_version + 1, (int) $e->revision_no), 'approved_by' => json_decode($e->approval, true, 512, JSON_THROW_ON_ERROR)['actor_id'], 'updated_at' => now()]);
DB::table('portal_resource_links')->where('resource_id', $r->id)->delete();
foreach ($payload['links'] as $link) {
DB::table('portal_resource_links')->insert([...Arr::except($link, ['id', 'resource_id', 'created_at', 'updated_at']), 'resource_id' => $r->id, 'created_at' => now(), 'updated_at' => now()]);
}
}
private function audit(User $actor, \stdClass $e, string $action, string $reason, ?string $nextState = null): void
{
$current = DB::table('portal_editorial_editions')->where('id', $e->id)->first();
app(DocumentManagementAccess::class)->audit($actor, 'editorial_'.$action, ['resource_id' => $e->resource_id, 'edition_id' => $e->id, 'hash' => $e->content_hash, 'version' => $e->version,
'from_state' => $e->state, 'to_state' => $nextState ?? $e->state, 'result_version' => $current->version, 'approval' => $current->approval, 'legal_validation' => $current->legal_validation, 'reason' => $reason]);
}
}
+10
View File
@@ -0,0 +1,10 @@
<?php
return [
// Development/QA opt-in. Installation and activation in an operational target are separate decisions.
'enabled' => env('PORTAL_EDITORIAL_WORKFLOW_ENABLED', false),
// Legal designation is explicit; management authority alone is not a legal designation.
'legal_reviewers' => [],
// Required separation is a policy, distinct from the legacy fact "independent_review".
'independent_approval' => ['resources' => [], 'kinds' => []],
];
@@ -0,0 +1,54 @@
<?php
use App\Support\IsolatedQaDatabase;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
if (! IsolatedQaDatabase::allowsSchemaChanges()) {
throw new RuntimeException('This first-stage migration is authorised only in isolated QA.');
}
Schema::create('portal_editorial_editions', function (Blueprint $t): void {
$t->uuid('id')->primary();
$t->foreignId('resource_id')->constrained('portal_resources')->restrictOnDelete();
$t->unsignedInteger('revision_no');
$t->unsignedInteger('version')->default(1);
$t->string('state', 24)->default('draft');
$t->jsonb('payload');
$t->char('content_hash', 64);
$t->foreignId('author_id')->constrained('users')->restrictOnDelete();
$t->foreignId('assigned_to')->nullable()->constrained('users')->restrictOnDelete();
$t->boolean('legal_required')->default(false);
$t->jsonb('approval')->nullable();
$t->jsonb('legal_validation')->nullable();
$t->timestampTz('published_at')->nullable();
$t->timestampsTz();
$t->unique(['resource_id', 'revision_no']);
$t->index(['state', 'assigned_to']);
});
Schema::create('portal_editorial_notifications', function (Blueprint $t): void {
$t->id();
$t->uuid('edition_id');
$t->foreign('edition_id')->references('id')->on('portal_editorial_editions')->restrictOnDelete();
$t->foreignId('user_id')->constrained('users')->restrictOnDelete();
$t->string('action', 40);
$t->timestampTz('read_at')->nullable();
$t->timestampTz('created_at');
$t->index(['user_id', 'read_at']);
});
}
public function down(): void
{
if (! IsolatedQaDatabase::allowsSchemaChanges() || DB::table('portal_editorial_editions')->exists() || DB::table('portal_editorial_notifications')->exists()) {
throw new RuntimeException('Preserve editions, decisions and history.');
}
Schema::dropIfExists('portal_editorial_notifications');
Schema::dropIfExists('portal_editorial_editions');
}
};
@@ -0,0 +1,39 @@
<?php
use App\Support\IsolatedQaDatabase;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
if (! IsolatedQaDatabase::allowsSchemaChanges()) {
throw new RuntimeException('Only isolated QA is authorised.');
}
Schema::table('portal_editorial_notifications', function (Blueprint $t): void {
$t->uuid('edition_id')->nullable()->change();
$t->foreignId('opportunity_id')->nullable()->constrained('opportunities')->restrictOnDelete();
$t->unsignedInteger('opportunity_version')->nullable();
$t->char('content_hash', 64)->nullable();
$t->unique(['user_id', 'opportunity_id', 'opportunity_version', 'action'], 'portal_editorial_op_notice_uq');
});
DB::statement('ALTER TABLE portal_editorial_notifications ADD CONSTRAINT portal_editorial_notice_subject CHECK ((edition_id IS NOT NULL AND opportunity_id IS NULL) OR (edition_id IS NULL AND opportunity_id IS NOT NULL AND opportunity_version IS NOT NULL))');
}
public function down(): void
{
if (! IsolatedQaDatabase::allowsSchemaChanges() || DB::table('portal_editorial_notifications')->whereNotNull('opportunity_id')->exists()) {
throw new RuntimeException('Preserve opportunity notification history.');
}
DB::statement('ALTER TABLE portal_editorial_notifications DROP CONSTRAINT portal_editorial_notice_subject');
Schema::table('portal_editorial_notifications', function (Blueprint $t): void {
$t->dropUnique('portal_editorial_op_notice_uq');
$t->dropForeign(['opportunity_id']);
$t->dropColumn(['opportunity_id', 'opportunity_version', 'content_hash']);
$t->uuid('edition_id')->nullable(false)->change();
});
}
};
@@ -1,3 +1,19 @@
## 13/11/2026 — Dashboard Colaborativo, Fase 1 implementada em desenvolvimento/QA
**Âmbito:** artigos, legislação, documentos e integração do circuito existente de oportunidades. A versão pública de um artigo permanece integralmente inalterada ao guardar ou rever uma nova edição privada, incluindo metadados, identificador e contador públicos. Não houve intervenção na homologação ou produção nesta passagem.
**Utilização em QA:** depois de instalar as duas migrações aditivas em base isolada e activar `PORTAL_EDITORIAL_WORKFLOW_ENABLED`, entrar pelo Painel/Gestão de conteúdos → Circuito editorial (`/management/editorial`). Alterar no editor existente, guardar a edição, conferir conteúdo/anexos e submeter com fundamento e responsável. O revisor inicia a revisão, valida juridicamente quando exigido e aprova. Publicar é uma segunda operação explícita. Devolução, rejeição e arquivo exigem fundamento. Arquivar um rascunho preserva a publicação anterior; arquivar a edição pública actual retira-a explicitamente. A fila permite filtrar tipo/estado e consultar versões, responsáveis e histórico. As notificações internas ligam à edição concreta ou à oportunidade no circuito nativo e podem ser marcadas como lidas.
**Autorizações e versões:** verificações no servidor para HTML, JSON, prévias e anexos; MFA existente nas escritas; escopos e permissões existentes, incluindo revogação de acesso. Aprovação e validação jurídica vinculadas à edição, conteúdo e versões/hashes dos anexos. Alterações ou bytes diferentes impedem reaproveitar a decisão. Conflitos de edição devolvem 409. Auto-aprovação bloqueada quando a política independente a exige, abrangendo todos os colaboradores da edição. A configuração `editorial_workflow.independent_approval` distingue a obrigação de independência do campo legado que apenas regista a revisão efectuada. A designação jurídica em `legal_reviewers` é explícita; não foram criadas contas, perfis ou concessões. A fonte oficial já aceite não é novamente auditada por este circuito.
**Oportunidades:** reutilizam filas, atribuições, revisões, publicador independente, prévias e histórico existentes. Novas submissões com a Fase 1 activa vinculam a validação jurídica à versão exacta e à versão dos anexos. Submissões já aprovadas mantêm o contrato/hash anterior. Notificações próprias e resumo de pendências entram no painel sem conceder acesso a outra organização ou conteúdo. Não existe um segundo motor de oportunidades.
**Verificação:** testes de integração do novo circuito, 11 aprovados/223 asserções; regressão e aceitação nativa de oportunidades, 6 aprovados/116 asserções, mais o caso reforçado de notificações isolado, 1 aprovado/20 asserções; unidade jurídica, 1 aprovado/6 asserções; frontend, 12 aprovados. PHPStan focado e tipos/compilação aprovados. A tentativa inicial nativa sem activos de compilação e com armazenamento de fixtures partilhado foi descartada; as provas aceites usam activos e armazenamento QA separados. Provas locais: `outputs/editorial-phase1-20261113/` (`integration-final.log`, `opportunity-acceptance.log`, `opportunity-notices.log`, `legal-unit.log`, `frontend-final.log`, `analysis-final.log`, `types-final.log`, `build-delivery.log`).
**Estrutura e preservação:** base própria `editorial_phase1_20261113_test`, PostgreSQL de QA 55441. Primeira migração criou duas tabelas vazias; segunda acrescentou o vínculo de notificações às oportunidades. Recibos `qa-installation.json` e `qa-notification-installation.json`: contagens das 175/177 tabelas preexistentes preservadas em cada etapa, excepto incremento documentado do livro de migrações. Sem preenchimento retroactivo, publicação automática, alteração de contas/permissões, nem escrita em bases operacionais. Reversão das migrações recusa apagar histórico existente; desactivar o novo módulo preserva dados e exige planear qualquer retoma de edição legada.
**Saldo:** implementado e demonstrado por testes em QA; conferência visual no navegador pendente. Activação operacional, designações jurídicas/independência efectivas e instalação fora do QA não executadas, dependem de decisão concreta. As migrações desta fase recusam destinos operacionais. Fase 2 (projectos âncora, clusters, estatística, demografia e conteúdos territoriais) continua delimitada para trabalho posterior; não é encerrada por esta entrega. Preservadas as entregas anteriores e o trabalho paralelo na pasta original.
## 13/11/2026 — pesquisa rápida: intenções e categorias, implementada em QA
A pesquisa reconhece contagens do catálogo publicado (por exemplo, «quantos corredores existem»), sem misturar projectos ou oportunidades apenas por mencionarem corredores. Zero registos e indisponibilidade de uma fonte têm apresentação distinta. «Como licenciar uma empresa» conduz ao roteiro existente e à preparação de S08 no dossier, sem inventar requisitos oficiais.
@@ -11128,3 +11128,4 @@ AUD-SIM-L1-P133,Saída de regimes suspensivos e operações de viagens,Refinamen
INE-DEMO-01,"Integração nacional de dados demográficos INE na viabilidade existente, importador em duas etapas, séries/versionamento, comparação territorial e adenda nacional.",Pedido colado pelo proprietário nesta sessão; execução expressamente confirmada; Macate/Vanduzi autorizados apenas em QA em 13/11/2026.,Território; viabilidade; percurso; SWOT; simulador,/localizacoes/demografia; /api/v1/investment/demography; /management/demography,DemographicImport; DemographicEvidence; leitor XLSX; cadastro/mapa/comparação/simulador e fontes SWOT existentes reutilizados.,"Homologação:4988ca89 instalado,erro500 de processos corrigido;preparaçõesCLI12/920456 privadas,por confirmaçãoMFA. Local:12lotes/920456 instalados e verificados.","154706 observações reais INE; três originais com scan real/hash; repetição sem duplicação; 72 reconciliações entre exemplares; comparação GONDOLA/GURO. Homologação: backup/restauro190 tabelas/369 ficheiros;11 tabelas de negócio preservadas;168 correspondências+15 distritos autorizados,sem pendências;HTTPS200 e revisão b20068d0 confirmada. Correcção df543a44:3testes/17asserções;HTTPS confirma revisão;ambos os titulares designados passam a autorização de leitura;nenhuma permissão alterada;hashes de código/configuração conferidos. Correcção500:2testes/20asserções sem proc_open;14testes/52asserções consumidores;8preparador;PHPStan aprovado. PHP-FPM real confirmou processos bloqueados,cacheCLI e antimalware disponíveis,hashes correctos,sem actor/sessão ou escrita de negócio;HTTPS confirma4988ca89.",Código instalado e verificado no PHP-FPM real;incorporação remota dos dados pendente da confirmação autenticada.,"Confirmar coortes initial/remaining/zambezia na sessão com MFA e conferir recibos,12 lotes/920456 observações e consulta territorial. Conferência visual administrativa pendente.",PostgreSQL/PostGIS QA; ClamAV; Python stdlib; autenticação e MFA existentes; nenhum novo aprovador.,"Três ficheiros associados aos territórios, valores exactos, séries 2017–2050, consulta sectorial/temporal, duas localizações, fontes/versões, inconsistências e acessos demonstrados em QA. Homologação manual restante registada no relatório.",Alta,,,,,"Consulta pública desktop/mobile conferida; visual administrativa pendente, testes HTTP aprovados.",11 testes de integração aprovados.,Três lotes QA; 154706 observações; idempotência e unicidade verificadas.,Consulta por território/ano/exemplar; desactivação/reaprovação preservam histórico.,"População/idade/residência, vitais, fontes e limitações; sem extrapolação, procura automática ou probabilidade de sucesso.",,,,,"Sem pendência de importação dos ficheiros provinciais fornecidos. Conferência visual administrativa,ressalvas/unidades ausentes e indicadores sem valores mantidos;outros ambientes não instalados por esta operação.","PortalProfileInstallation/MFA existentes; DemographicInstallation; DemographicImport; InvestmentGeography; simulador, mapa/comparação e fontes SWOT existentes.",,Proprietário executou a disponibilização autorizada de Zambezia.xlsx e cinco distritos;recibo autenticado e resultado confirmados sem repetição.,,,"outputs/demography-20261113/zambezia/installation-status.json e verification.json:recibo autenticado09:33:38UTC,hash/lote/count126684,total12lotes/920456,preservação11lotes e oito tabelas de negócio,série provincial e cinco distritos públicos. operational-status.json actualizado;ensaios e backup/restauro anteriores preservados.",demografia-ine,13/11/2026 — instalação autenticada concluída; conferência técnica de leitura sem repetição da operação,,,,,,,,,,,,,,
REL-DOSSIER-20261113,"Agrupar no dossier correspondente os relatórios produzidos pelas etapas, com versão, oportunidade e recuperação.",Pedido expresso do proprietário em 13/11/2026: «tem de ser resolvido».,Percurso do investidor; dossier; relatórios,/investimentos/{id}#relatorios; /investimentos/{id}/relatorios/fiscais/{reportId},"Índice e relatório consolidado no percurso existente; reutiliza os geradores Word/HTML, cálculos e cópias preservadas.",Homologação teste-investidor.tempo.co.mz instalada em 07182c7c; /investimentos/{id}#relatorios.,"21 testes frontend; regressão servidor 4/168 asserções e caso final 45 asserções; tipos,lint,PHPStan e build aprovados. HTTPS e preservação conferidos na revisão instalada.",Consolidado instalado em 07182c7c; resumos por etapa conferidos em QA; delta sem instalação,Novo dossier sintético: 19 consultas e 38 descargas reais Word/HTML conferidas; recuperação após actualização. Falta instalar o delta por etapa; saídas específicas ainda não produzidas mantêm o saldo próprio.,,"Mesmo dossier/oportunidade → índice por etapa → relatório Word/HTML → acesso a versões APIEX, fiscais e documentos → recuperação sem misturar oportunidades ou expor finanças a gestores.",Alta,,,,,Índice responsivo e exportação consolidada,"Descarga privada da cópia fiscal preservada, autorizada por titular e dossier","Reutiliza os registos existentes; associação fiscal nova conserva âmbito, sem reescrever fontes anteriores.",Aprovada em prova sintética; sem depender de acesso à simulação anónima original.,Versão/data/estado/origem; pendências e âmbito por confirmar explícitos.,,,,,,,,"Acrescentar índice e consolidar saídas existentes; sem módulo paralelo, publicação ou envio automático.",Consulta e descargas por etapa concluídas em QA; instalação do novo delta pendente,,outputs/dossier-reports-20261113/verification.json; outputs/dossier-reports-20261113/homologation-installed.json; outputs/dossier-reports-20261113/stage-authenticated-download-verification.json,,,,,,,,,,,,,,,,
ACT-20261113-PESQUISA,Pesquisa rápida: contar corredores sem misturar referências; produtos físicos e financeiros separados por categoria e localização; como licenciar empresa.,Pedidos Tempo na conversa de 13/11/2026,Pesquisa rápida,/pesquisa; /,"SearchTerms::requestIntent, GlobalSearchController, FederatedSearch, portal/search, portal-hero; reutiliza catálogos publicados existentes.",Implementado em QA; nova candidata ainda não instalada.,"outputs/quick-search-20261113/: integration.log, regression.log, unit.log, frontend.log, types.log, analysis.log, build.log",QA implementado e testes focados aprovados; instalação e visual pendentes,Instalar após autorização concreta; conferir apresentação no navegador.,,Contagem tipada e zero distinto de falha; produtos separados com localização/cobertura e contexto nos links; licenciamento no roteiro/dossier existentes; só publicados; erros de fontes explícitos; paginação preservada.,Alta,,,,,,,,,,,,,,"Conferência visual e instalação desta candidata; pesquisa territorial é textual, sem inferir elegibilidade.",,,,Implementação QA concluída neste recorte; entrega preparada.,,,,,,,,,,,,,,,,,,
ACT-20261113-EDITORIAL-F1,"Dashboard Colaborativo Fase 1: versões e autorizações; artigos, legislação, oportunidades e documentos; filas e notificações",Directiva Tempo 13/11/2026 nesta sessão; plano aprovado e autorização de implementação apenas em desenvolvimento/QA,Gestão editorial / conteúdos,/management/editorial; /management/editorial/resources/{id}; edições/anexos/notificações; editores e circuito nativo de oportunidades,Edições privadas imutáveis no circuito; versão pública preservada; aprovação/publicação separadas; decisões ligadas a conteúdo/anexos; permissões e auditoria reutilizadas; notificações com acesso revogável,Desactivado por defeito fora dos testes; estrutura instalada apenas em editorial_phase1_20261113_test:55441,outputs/editorial-phase1-20261113: integração 11/223; oportunidades 6/116 + caso reforçado 1/20; unidade 1/6; frontend 12; PHPStan/tipos/build aprovados,Implementado em QA; conferência visual e instalação operacional pendentes,Conferência visual; configuração operacional de designações e independência; instalação/activação fora do QA não executadas,Decisão concreta para instalação operacional e designações; não bloqueia as provas sintéticas QA,Editar preserva toda a publicação; JSON/prévias/anexos autorizados; conteúdo/anexos novos exigem nova decisão; aprovação distinta da publicação; filas e notificações isoladas; conflitos 409; histórico recuperável,Alta,,,,,"Fila, filtros, edição, histórico e notificações em QA","JSON nas mesmas rotas privadas, com as mesmas autorizações","Edições, decisões, notificações e auditoria; dados sintéticos revertidos pelos testes",Versões e publicação anterior recuperadas; isolamento/revogação demonstrados,Versão pública mantida durante edição e revisão; substituição só por publicação explícita,,,,"Implementação focada em lacunas, sem segundo motor de oportunidades ou alteração de permissões",Conferência visual; instalação operacional separada; Fase 2 não implementada,"DocumentManagementAccess, PortalProfileAccess, EditorialReading, VerifiedDocumentBinary, auditoria e circuito nativo de oportunidades",,Implementar em desenvolvimento/QA; não intervir em produção,Implementação aditiva e testes focados,,Recibos QA e testes em outputs/editorial-phase1-20261113; manual unificado actualizado,Dashboard Colaborativo Fase 1,13/11/2026,,,,,,,,"Reutilização dos testes nativos de oportunidade, permissão, prévias e integridade",Versão pública preservada; circuito editorial e decisões por versão/anexos; escopos de notificações e conteúdos,Sem conferência visual; sem operação em homologação/produção; tentativa inicial descartada explicitamente,,Percursos editoriais e autorizações,Artigos → legislação/documentos → oportunidades reutilizadas → fila/painel/notificações,QA demonstrado; instalação não executada; Fase 2 pendente
Can't render this file because it is too large.
+17
View File
@@ -36,10 +36,12 @@ export default function Dashboard({
investorPath,
opportunityManagement,
workQueue,
editorialQueue,
}: {
summary: Summary | null;
opportunityManagement?: OpportunityManagementAccess | null;
workQueue?: DashboardQueue | null;
editorialQueue?: { pending: number; unread: number } | null;
investorPath?: {
contexts: InvestmentContext[];
current: {
@@ -59,6 +61,21 @@ export default function Dashboard({
<ProfileNavigation />
<ContentManagementEntry />
<DashboardWorkQueue queue={workQueue} />
{editorialQueue && (
<section
className="rounded-lg border p-4"
aria-label="Trabalho editorial"
>
<h2 className="font-semibold">Circuito editorial</h2>
<p>
{editorialQueue.pending} conteúdos pendentes ·{' '}
{editorialQueue.unread} notificações por ler
</p>
<Link className="underline" href="/management/editorial">
Abrir fila editorial e histórico
</Link>
</section>
)}
{summary ? (
<>
<Link
@@ -3,6 +3,7 @@ import { Frame, card, button } from '@/pages/investment/shared';
import { states } from './shared';
type Access = {
editorial?: { index: string };
procurement?: { index: string; create: string };
opportunities?: {
index: string;
@@ -26,6 +27,20 @@ export default function ContentHub({
acção respeita a sua autorização actual.
</p>
<div className="grid gap-4 md:grid-cols-2">
{access.editorial && (
<section className={card}>
<h2 className="text-xl font-semibold">
Circuito editorial
</h2>
<p>
Preparar, rever, aprovar e publicar versões de
artigos, legislação e documentos.
</p>
<Link className={button} href={access.editorial.index}>
Abrir fila editorial
</Link>
</section>
)}
{access.procurement && (
<section className={card}>
<h2 className="text-xl font-semibold">
@@ -0,0 +1,331 @@
import { Link, useForm } from '@inertiajs/react';
import { Management, Field, button, control } from './shared';
import { states } from './editorial-workflow';
type Edition = {
id: string;
revision_no: number;
version: number;
state: string;
content_hash: string;
legal_required: boolean;
fresh: boolean;
payload: {
resource: {
title: string;
description?: string;
editorial_content?: string | null;
};
files: {
id: string;
file_name: string;
version_no: number;
sha256: string;
}[];
};
approval: string | null;
legal_validation: string | null;
};
export default function EditorialEdition({
resource,
edition,
manager,
legalReviewer,
history,
editions,
reviewers,
current,
canApprove,
independentRequired,
}: {
resource: {
id: number;
title: string;
published: boolean;
public_edition_no?: number | null;
};
edition: Edition | null;
manager: boolean;
legalReviewer: boolean;
history: {
id: number;
action: string;
actor_user_id: number;
after_data: string;
occurred_at: string;
}[];
editions: { id: string; revision_no: number; state: string }[];
reviewers: { id: number; name: string }[];
current: boolean;
canApprove: boolean;
independentRequired: boolean;
}) {
const form = useForm({
version: edition?.version ?? 1,
action: '',
reason: '',
assigned_to: '',
});
const available: Record<string, string[]> = {
draft: ['submit', 'archive'],
returned: ['submit', 'archive'],
submitted: ['start_review', 'archive'],
in_review: ['return', 'reject', 'legal_validate', 'approve', 'archive'],
approved: ['publish', 'archive'],
rejected: ['archive'],
published: ['archive'],
archived: [],
};
const labels: Record<string, string> = {
submit: 'Submeter',
start_review: 'Iniciar revisão',
return: 'Devolver',
reject: 'Rejeitar',
legal_validate: 'Validar juridicamente',
approve: 'Aprovar',
publish: 'Publicar',
archive: 'Arquivar',
};
return (
<Management title={`Edição — ${resource.title}`}>
<div className="flex flex-wrap gap-4">
<Link className="underline" href="/management/editorial">
Fila e notificações
</Link>
<Link
className="underline"
href={`/management/portal-services/resources/${resource.id}/article`}
>
Alterar artigo
</Link>
<Link className="underline" href="/management/portal-services">
Alterar metadados/documento
</Link>
</div>
<p>
{resource.published
? 'A versão pública continua disponível até uma publicação ou retirada explícita.'
: 'Ainda não existe uma versão pública disponível.'}
</p>
{resource.public_edition_no && (
<p>Versão pública: edição {resource.public_edition_no}.</p>
)}
{edition ? (
<>
<h2 className="font-semibold">
Edição {edition.revision_no} ·{' '}
{states[edition.state] ?? edition.state}
</h2>
{!edition.fresh && (
<p role="alert">
Os anexos mudaram ou deixaram de estar disponíveis.
Guarde uma nova edição e volte a conferir antes da
aprovação.
</p>
)}
<p className="text-sm break-all">
Identificador da edição: {edition.id}
<br />
Hash do conteúdo e anexos: {edition.content_hash}
</p>
<h3 className="font-semibold">Conferir conteúdo</h3>
<p>{edition.payload.resource.title}</p>
<p className="whitespace-pre-wrap">
{edition.payload.resource.description}
</p>
{edition.payload.resource.editorial_content && (
<p className="whitespace-pre-wrap">
{
(
JSON.parse(
edition.payload.resource
.editorial_content,
) as { body?: string }
).body
}
</p>
)}
<ul>
{edition.payload.files.map((file) => (
<li key={file.id}>
<a
className="underline"
href={`/management/editorial/resources/${resource.id}/editions/${edition.id}/files/${file.id}`}
target="_blank"
rel="noopener noreferrer"
>
{file.file_name} · versão {file.version_no}
</a>
<span className="block text-xs break-all">
SHA256: {file.sha256}
</span>
</li>
))}
</ul>
<p>
{edition.legal_required
? 'Validação jurídica obrigatória para esta edição.'
: 'Sem validação jurídica obrigatória definida para este tipo de conteúdo.'}
</p>
<p>
{edition.state === 'published' && !edition.approval
? 'Publicação anterior conservada, sem fabricar novas aprovações.'
: `Aprovação: ${edition.approval ? 'Registada nesta edição' : 'Pendente'}`}{' '}
· Validação jurídica:{' '}
{edition.legal_validation
? 'Registada nesta edição'
: 'Não registada'}
</p>
{independentRequired && current && (
<p>
Esta edição exige um responsável independente para a
aprovação.
</p>
)}
{current ? (
<form
key={edition.version}
onSubmit={(e) => {
e.preventDefault();
form.transform((data) => ({
...data,
version: edition.version,
assigned_to: data.assigned_to || null,
}));
form.post(
`/management/editorial/resources/${resource.id}/editions/${edition.id}`,
{
onSuccess: () =>
form.reset('action', 'reason'),
},
);
}}
className="space-y-3"
>
<Field label="Operação">
<select
required
className={control}
value={form.data.action}
onChange={(e) =>
form.setData('action', e.target.value)
}
>
<option value="">Seleccionar</option>
{(available[edition.state] ?? [])
.filter(
(action) =>
(manager ||
[
'submit',
'archive',
].includes(action)) &&
(action !== 'legal_validate' ||
(legalReviewer &&
canApprove)) &&
(action !== 'approve' ||
canApprove) &&
(edition.fresh ||
action === 'archive'),
)
.map((action) => (
<option key={action} value={action}>
{labels[action]}
</option>
))}
</select>
</Field>
{form.data.action === 'submit' && (
<Field label="Responsável pela revisão">
<select
className={control}
value={form.data.assigned_to}
onChange={(e) =>
form.setData(
'assigned_to',
e.target.value,
)
}
>
<option value="">Por atribuir</option>
{reviewers.map((reviewer) => (
<option
key={reviewer.id}
value={reviewer.id}
>
{reviewer.name}
</option>
))}
</select>
</Field>
)}
<Field label="Fundamento">
<textarea
required
minLength={3}
maxLength={5000}
className={control}
value={form.data.reason}
onChange={(e) =>
form.setData('reason', e.target.value)
}
/>
</Field>
{Object.entries(form.errors).map(([key, error]) => (
<p key={key} role="alert">
{error}
</p>
))}
<button
className={button}
disabled={form.processing || !form.data.action}
>
Registar operação
</button>
</form>
) : (
<p>
Versão histórica para consulta.{' '}
<Link
className="underline"
href={`/management/editorial/resources/${resource.id}`}
>
Voltar à edição actual
</Link>
</p>
)}
</>
) : (
<p>
Prepare uma edição no editor existente para iniciar a
revisão.
</p>
)}
<h2 className="font-semibold">Versões conservadas</h2>
<ul>
{editions.map((e) => (
<li key={e.id}>
<Link
className="underline"
href={`/management/editorial/resources/${resource.id}?edition=${e.id}`}
>
Edição {e.revision_no}: {states[e.state] ?? e.state}
</Link>
</li>
))}
</ul>
<h2 className="font-semibold">Histórico de operações</h2>
<ul>
{history.map((event) => (
<li key={event.id}>
{event.occurred_at} ·{' '}
{labels[event.action.replace('editorial_', '')] ??
'Edição guardada'}{' '}
· Utilizador {event.actor_user_id}
<p>{JSON.parse(event.after_data)?.reason}</p>
</li>
))}
</ul>
</Management>
);
}
@@ -0,0 +1,202 @@
import { Link, router } from '@inertiajs/react';
import { Field, Management, button, control } from './shared';
export const states: Record<string, string> = {
draft: 'Rascunho',
submitted: 'Submetido',
in_review: 'Em revisão',
approved: 'Aprovado',
published: 'Publicado',
returned: 'Devolvido',
rejected: 'Rejeitado',
archived: 'Arquivado',
};
type Item = {
id: number;
title: string;
revision_no: number;
state: string;
responsible: string | null;
published: boolean;
legal_required: boolean;
};
const eventTitles: Record<string, string> = {
submit: 'Submissão',
start_review: 'Início da revisão',
return: 'Devolução',
reject: 'Rejeição',
legal_validate: 'Validação jurídica',
approve: 'Aprovação',
publish: 'Publicação',
archive: 'Arquivo',
};
export default function EditorialWorkflow({
items,
notices,
filters = {},
resourceAccess,
opportunitiesUrl,
opportunityPending,
}: {
resourceAccess: boolean;
opportunitiesUrl: string | null;
opportunityPending: number;
filters?: { state?: string; kind?: string };
items: { data: Item[]; current_page: number; last_page: number };
notices: {
id: number;
action: string;
read_at: string | null;
href: string;
reference: string;
}[];
}) {
const pageUrl = (page: number) =>
`/management/editorial?${new URLSearchParams({ ...filters, page: String(page) })}`;
return (
<Management title="Circuito editorial">
<p>
Artigos, legislação e documentos: edições privadas e versão
pública separadas. As oportunidades mantêm o seu circuito
próprio.
</p>
<div className="flex flex-wrap gap-4">
<Link className="underline" href="/dashboard">
Painel geral
</Link>
{resourceAccess && (
<Link
className="underline"
href="/management/portal-services"
>
Biblioteca e editores
</Link>
)}
{opportunitiesUrl && (
<Link className="underline" href={opportunitiesUrl}>
Oportunidades
</Link>
)}
</div>
{opportunitiesUrl && (
<section
aria-label="Circuito de oportunidades"
className="my-4 rounded border p-4"
>
<h2 className="font-semibold">Oportunidades</h2>
<p>
{opportunityPending} conteúdos pendentes no seu âmbito
de acesso.
</p>
<Link className="underline" href={opportunitiesUrl}>
Continuar na fila de oportunidades existente
</Link>
</section>
)}
{resourceAccess && (
<>
<form
action="/management/editorial"
className="my-4 flex flex-wrap items-end gap-3"
>
<Field label="Conteúdo">
<select
className={control}
name="kind"
defaultValue={filters.kind ?? ''}
>
<option value="">Todos</option>
<option value="article">Artigos</option>
<option value="legislation">Legislação</option>
<option value="document">Documentos</option>
</select>
</Field>
<Field label="Estado da edição">
<select
className={control}
name="state"
defaultValue={filters.state ?? ''}
>
<option value="">Todos</option>
{Object.entries(states).map(
([state, label]) => (
<option key={state} value={state}>
{label}
</option>
),
)}
</select>
</Field>
<button className={button}>Filtrar fila</button>
</form>
<section aria-label="Fila editorial" className="space-y-3">
{items.data.map((item) => (
<article
key={item.id}
className="rounded border p-4"
>
<Link
className="font-semibold underline"
href={`/management/editorial/resources/${item.id}`}
>
{item.title}
</Link>
<p>
Edição {item.revision_no} ·{' '}
{states[item.state] ?? item.state} ·
Responsável:{' '}
{item.responsible ?? 'Por atribuir'}
</p>
<p>
{item.published
? 'Versão pública disponível'
: 'Sem versão pública'}
{item.legal_required &&
' · Exige validação jurídica'}
</p>
</article>
))}
</section>
<nav aria-label="Paginação" className="flex gap-4">
{items.current_page > 1 && (
<Link href={pageUrl(items.current_page - 1)}>
Anterior
</Link>
)}
<span>
Página {items.current_page} de {items.last_page}
</span>
{items.current_page < items.last_page && (
<Link href={pageUrl(items.current_page + 1)}>
Seguinte
</Link>
)}
</nav>
</>
)}
<section aria-label="Notificações editoriais">
<h2 className="font-semibold">Notificações</h2>
{notices.map((n) => (
<p key={n.id}>
<Link className="underline" href={n.href}>
{eventTitles[n.action] ?? 'Alteração editorial'} ·{' '}
{n.reference}
</Link>
{!n.read_at && (
<button
className="ml-3 underline"
onClick={() =>
router.post(
`/management/editorial/notices/${n.id}/read`,
)
}
>
Marcar como lida
</button>
)}
</p>
))}
</section>
</Management>
);
}
+18 -2
View File
@@ -15,6 +15,8 @@ export default function ArticleEditor({
source,
images,
revisions,
workflow,
workflowEnabled = false,
}: {
resource: {
id: number;
@@ -36,6 +38,8 @@ export default function ArticleEditor({
change_type: string;
created_at: string;
}[];
workflowEnabled?: boolean;
workflow?: { id: string; state: string; revision_no: number } | null;
}) {
const initial = () => ({
body: content?.body ?? '',
@@ -106,9 +110,21 @@ export default function ArticleEditor({
<Management title={`Editar artigo — ${resource.title}`}>
<p>
O original, a origem e as versões permanecem na biblioteca.
Guardar cria uma revisão editorial e retira a publicação até à
sua acção expressa no acervo.
{workflowEnabled
? 'Guardar prepara uma edição privada e conserva a versão pública até à publicação explícita da nova edição aprovada.'
: 'No editor anterior, guardar retira a publicação até à sua acção expressa no acervo.'}
</p>
{workflow && (
<p>
<Link
className="underline"
href={`/management/editorial/resources/${resource.id}`}
>
Conferir edição {workflow.revision_no}, revisão e
publicação
</Link>
</p>
)}
<p role="status">
Revisão guardada {resource.metadata_version} ·{' '}
{resource.published ? 'Publicado' : 'Rascunho privado'}
+56 -40
View File
@@ -166,7 +166,9 @@ export default function Manage({
editorialScopeOnly = false,
managementFilters = {},
associationOptions = {},
editorialWorkflowEnabled = false,
}: {
editorialWorkflowEnabled?: boolean;
managementFilters?: { q?: string; state?: string; intent?: string };
associationOptions?: Record<
string,
@@ -984,20 +986,24 @@ export default function Manage({
/>
Destacar como essencial na página inicial
</label>
<label className="flex gap-2">
<input
type="checkbox"
checked={form.data.independent_review}
onChange={(e) =>
form.setData(
'independent_review',
e.target.checked,
)
}
/>
Foi realizada revisão independente por outro
gestor
</label>
{!editorialWorkflowEnabled && (
<label className="flex gap-2">
<input
type="checkbox"
checked={
form.data.independent_review
}
onChange={(e) =>
form.setData(
'independent_review',
e.target.checked,
)
}
/>
Foi realizada revisão independente por
outro gestor
</label>
)}
<fieldset className="space-y-3 rounded border p-4">
<legend className="font-semibold">
Relações com etapas, módulos e
@@ -1401,7 +1407,7 @@ export default function Manage({
</div>
</form>
{disclosureAuthorization && (
{disclosureAuthorization && !editorialWorkflowEnabled && (
<section
className="space-y-3 rounded border border-emerald-700 bg-emerald-50 p-5"
aria-labelledby="disclosure-authorization-title"
@@ -1684,31 +1690,41 @@ export default function Manage({
>
Ler / pré-visualizar artigo
</a>
<button
type="button"
className={button}
onClick={() => {
setActionError('');
router.post(
`/management/portal-services/resources/${r.id}`,
{ published: !r.published },
{
preserveScroll: true,
onError: (errors) =>
setActionError(
Object.values(
errors,
)[0] ??
'A transição foi recusada; confirme o estado do ficheiro, fonte e direitos.',
),
},
);
}}
>
{r.published
? 'Retirar da publicação'
: 'Publicar'}
</button>
{editorialWorkflowEnabled && (
<a
className={button}
href={`/management/editorial/resources/${r.id}`}
>
Revisão, aprovação e publicação
</a>
)}
{(!editorialWorkflowEnabled || r.published) && (
<button
type="button"
className={button}
onClick={() => {
setActionError('');
router.post(
`/management/portal-services/resources/${r.id}`,
{ published: !r.published },
{
preserveScroll: true,
onError: (errors) =>
setActionError(
Object.values(
errors,
)[0] ??
'A transição foi recusada; confirme o estado do ficheiro, fonte e direitos.',
),
},
);
}}
>
{r.published
? 'Retirar da publicação'
: 'Publicar'}
</button>
)}
</div>
{actionError && <p role="alert">{actionError}</p>}
</article>
+6
View File
@@ -37,6 +37,7 @@ use App\Http\Controllers\OpportunityManagementController;
use App\Http\Controllers\OpportunityOriginReviewController;
use App\Http\Controllers\OpportunityPromotionController;
use App\Http\Controllers\OpportunityResourceController;
use App\Http\Controllers\PortalEditorialWorkflowController;
use App\Http\Controllers\PortalManagementController;
use App\Http\Controllers\PortalProfileController;
use App\Http\Controllers\PortalProfileInstallationController;
@@ -95,6 +96,11 @@ Route::get('/explorar/{sectorSlug}', [LegacyContentController::class, 'landing']
Route::get('/{legacySlug}', [LegacyContentController::class, 'article'])->whereIn('legacySlug', array_keys(config('legacy_content.articles', [])));
Route::post('/apoio', [PortalServicesController::class, 'submit'])->middleware('throttle:10,1')->name('services.submit');
Route::middleware(['auth', 'verified'])->group(function () {
Route::get('/management/editorial', [PortalEditorialWorkflowController::class, 'index'])->name('editorial.workflow');
Route::get('/management/editorial/resources/{id}', [PortalEditorialWorkflowController::class, 'show'])->whereNumber('id');
Route::post('/management/editorial/resources/{id}/editions/{edition}', [PortalEditorialWorkflowController::class, 'transition'])->whereNumber('id')->whereUuid('edition');
Route::get('/management/editorial/resources/{id}/editions/{edition}/files/{document}', [PortalEditorialWorkflowController::class, 'file'])->whereNumber('id')->whereUuid(['edition', 'document']);
Route::post('/management/editorial/notices/{notice}/read', [PortalEditorialWorkflowController::class, 'markRead'])->whereNumber('notice');
Route::get('/gestao-conteudos', fn (Request $request) => Inertia::render('management/content-hub', app(ContentManagement::class)->view($request->user())))->name('content-management');
Route::get('/balcao-virtual', [PortalServicesController::class, 'page'])->defaults('page', 'desk')->name('services.desk');
Route::post('/parcerias', [PortalServicesController::class, 'partnership'])->middleware('throttle:10,1')->name('services.partnership');
@@ -160,6 +160,82 @@ class OpportunityCanonicalWorkflowTest extends TestCase
$this->assertNotNull($response['materiality']);
}
public function test_phase_one_requires_a_new_legal_decision_for_each_content_and_attachment_version(): void
{
config(['editorial_workflow.enabled' => true]);
$this->withoutVite();
[$id, $promoter, $validator, $publisher, $payload, $document] = $this->fixture();
$this->action($promoter, $id, 'submit');
foreach (['property_review', 'registry_urban_review', 'building_compliance_review'] as $gate) {
$this->action($validator, $id, 'review_gate', ['gate' => $gate, 'gate_decision' => 'approved', 'evidence_document_ids' => [$document]]);
}
$this->action($validator, $id, 'approve');
$signed = Opportunity::findOrFail($id)->workflow_context['reviews']['registry_urban_review'];
$this->action($promoter, $id, 'request_revision');
$payload['official_title'] .= ' — edição privada da Fase 1';
$this->loginFixture($promoter)->putJson('/minhas-oportunidades/'.$id.'/draft', ['etag' => app(OpportunityManagement::class)->etag(Opportunity::findOrFail($id)), 'canonical' => $payload, 'reason' => self::REASON])->assertOk();
$this->action($promoter, $id, 'submit');
$context = Opportunity::findOrFail($id)->workflow_context;
$this->assertContains('registry_urban_review', $context['materiality']['reopened']);
$this->assertArrayNotHasKey('registry_urban_review', $context['reviews']);
$this->assertSame($signed['hash'], $context['decision_history'][0]['reviews']['registry_urban_review']['hash']);
$this->action($validator, $id, 'approve', [], 422);
$this->action($validator, $id, 'review_gate', ['gate' => 'registry_urban_review', 'gate_decision' => 'approved', 'evidence_document_ids' => [$document]]);
$this->action($validator, $id, 'approve');
$this->action($publisher, $id, 'assign_publication', ['assignee_user_id' => $publisher->id]);
$file = DB::table('opportunity_documents')->where('id', $document)->value('document_id');
DB::table('documents')->where('id', $file)->increment('version_no');
$this->action($publisher, $id, 'publish', [], 409);
}
public function test_phase_one_activation_preserves_an_approved_legacy_submission_without_rewriting_its_hash(): void
{
config(['editorial_workflow.enabled' => false]);
$this->withoutVite();
[$id, $promoter, $validator, $publisher, , $document] = $this->fixture();
$this->action($promoter, $id, 'submit');
foreach (['property_review', 'registry_urban_review', 'building_compliance_review'] as $gate) {
$this->action($validator, $id, 'review_gate', ['gate' => $gate, 'gate_decision' => 'approved', 'evidence_document_ids' => [$document]]);
}
$this->action($validator, $id, 'approve');
$approved = Opportunity::findOrFail($id)->workflow_context['approval']['hash'];
config(['editorial_workflow.enabled' => true]);
$this->action($publisher, $id, 'assign_publication', ['assignee_user_id' => $publisher->id]);
$this->action($publisher, $id, 'publish');
$record = Opportunity::findOrFail($id);
$this->assertSame('published', $record->status);
$this->assertSame($approved, $record->workflow_context['approval']['hash']);
$this->assertArrayNotHasKey('editorial_version_binding', $record->workflow_context);
}
public function test_phase_one_opportunity_notices_follow_current_access_and_do_not_grant_resource_access(): void
{
config(['editorial_workflow.enabled' => true]);
$this->withoutVite();
[$id, $promoter, $validator] = $this->fixture();
$this->action($promoter, $id, 'submit');
$this->action($validator, $id, 'start_review');
$notice = DB::table('portal_editorial_notifications')->where('user_id', $promoter->id)->where('opportunity_id', $id)->first();
$this->assertNotNull($notice);
$this->assertNotNull($notice->opportunity_version);
$data = $this->loginFixture($promoter)->getJson('/management/editorial')->assertOk()->assertJsonPath('resourceAccess', false)->assertJsonPath('items.total', 0)->json();
$this->assertNotEmpty($data['notices']);
$this->assertSame('/minhas-oportunidades/'.$id, $data['notices'][0]['href']);
$this->postJson('/management/editorial/notices/'.$notice->id.'/read')->assertOk();
$this->assertNotNull(DB::table('portal_editorial_notifications')->where('id', $notice->id)->value('read_at'));
$administrator = User::factory()->create();
config(['portal.managers' => [$administrator->email]]);
DB::table('portal_editorial_notifications')->insert(['user_id' => $administrator->id, 'edition_id' => null, 'opportunity_id' => $id,
'opportunity_version' => $notice->opportunity_version, 'action' => 'start_review', 'created_at' => now()]);
$this->loginFixture($administrator)->getJson('/management/editorial')->assertOk()->assertJsonPath('resourceAccess', true)->assertJsonPath('notices', [])->assertJsonPath('opportunityPending', 0);
$adminNotice = DB::table('portal_editorial_notifications')->where('user_id', $administrator->id)->value('id');
$this->postJson('/management/editorial/notices/'.$adminNotice.'/read')->assertNotFound();
$this->loginFixture($promoter);
DB::table('portal_role_assignments')->where('user_id', $promoter->id)->update(['revoked_at' => now()]);
$this->getJson('/management/editorial')->assertForbidden();
$this->postJson('/management/editorial/notices/'.$notice->id.'/read')->assertNotFound();
}
public function test_geometry_order_is_material_and_object_key_order_is_not(): void
{
$materiality = app(OpportunityReviewMateriality::class);
@@ -0,0 +1,304 @@
<?php
namespace Tests\Feature;
use App\Models\User;
use App\Services\PortalEditorialWorkflow;
use App\Support\IsolatedQaDatabase;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Inertia\Testing\AssertableInertia as Assert;
use Tests\Support\AuthenticatesQaActors;
use Tests\TestCase;
final class PortalEditorialWorkflowTest extends TestCase
{
use AuthenticatesQaActors, DatabaseTransactions;
private User $author;
private User $reviewer;
private User $legal;
private User $publisher;
private int $resource;
protected function setUp(): void
{
parent::setUp();
$this->assertTrue(IsolatedQaDatabase::allowsSchemaChanges());
if (! Schema::hasTable('portal_editorial_editions')) {
(require database_path('migrations/2026_11_13_220000_create_portal_editorial_editions.php'))->up();
}
$this->assertTrue(Schema::hasColumns('portal_editorial_editions', ['payload', 'content_hash', 'state', 'version', 'approval', 'legal_validation']));
if (! Schema::hasColumn('portal_editorial_notifications', 'opportunity_id')) {
(require database_path('migrations/2026_11_13_223000_extend_editorial_notifications_to_opportunities.php'))->up();
}
config(['editorial_workflow.enabled' => true, 'editorial_workflow.independent_approval.kinds' => ['article', 'legislation', 'document']]);
$this->withoutVite();
Storage::fake('local');
[$this->author, $this->reviewer, $this->legal, $this->publisher] = User::factory()->count(4)->create()->all();
config(['portal.managers' => [$this->author->email, $this->reviewer->email, $this->legal->email, $this->publisher->email], 'editorial_workflow.legal_reviewers' => [$this->legal->id]]);
$document = $this->document('Conteúdo público original.');
$this->resource = DB::table('portal_resources')->insertGetId(['document_id' => $document, 'title' => 'Artigo sintético QA', 'category' => 'guide', 'topic' => 'qa', 'document_type' => 'guide',
'published' => true, 'approved_by' => $this->reviewer->id, 'created_by' => $this->author->id, 'source_reference' => 'Fonte sintética de teste isolado', 'source_url' => 'https://example.invalid/source',
'source_confirmation' => 'identified', 'rights_status' => 'authorized', 'independent_review' => true, 'metadata_version' => 1,
'editorial_content' => json_encode(['body' => 'Conteúdo público original.', 'image_mode' => 'none', 'preserve_original_html' => false]), 'created_at' => now(), 'updated_at' => now()]);
}
private function document(string $body): string
{
$id = (string) Str::uuid();
$bytes = json_encode(['format' => 'investmoz-legacy-article-v1', 'content_html' => '<p>'.$body.'</p>']);
$hash = hash('sha256', $bytes);
Storage::disk('local')->put('qa/'.$id.'.json', $bytes);
DB::table('documents')->insert(['id' => $id, 'file_name' => 'synthetic.json', 'media_type' => 'application/json', 'storage_key' => 'qa/'.$id.'.json',
'sha256' => $hash, 'malware_scanned_sha256' => $hash, 'malware_scan_status' => 'clean', 'rights_status' => 'authorized', 'status' => 'active', 'data_classification' => 'public', 'source_system' => 'portal-services', 'uploaded_by' => $this->author->id]);
return $id;
}
private function save(array $fields = []): \stdClass
{
$this->actingAsWithMfa($this->author);
$resource = DB::table('portal_resources')->where('id', $this->resource)->first();
return app(PortalEditorialWorkflow::class)->save($this->author, $this->resource, (int) app(PortalEditorialWorkflow::class)->workingResource($resource)->metadata_version, $fields);
}
private function action(User $actor, \stdClass $edition, string $action, int $status = 200, array $extra = [])
{
$this->actingAsWithMfa($actor);
$response = $this->postJson('/management/editorial/resources/'.$this->resource.'/editions/'.$edition->id, ['version' => $edition->version, 'action' => $action, 'reason' => 'Decisão sintética fundamentada QA', ...$extra])->assertStatus($status);
return $status === 200 ? DB::table('portal_editorial_editions')->where('id', $edition->id)->first() : $response;
}
public function test_editing_and_private_preview_preserve_the_public_article_and_its_original_version(): void
{
$before = DB::table('portal_resources')->where('id', $this->resource)->first();
$this->actingAsWithMfa($this->author)->put('/management/portal-services/resources/'.$this->resource.'/article', ['metadata_version' => 1, 'body' => 'Nova edição privada.', 'image_mode' => 'none'])->assertRedirect();
$r = DB::table('portal_resources')->where('id', $this->resource)->first();
$this->assertEquals($before, $r, 'The complete public resource must remain unchanged while a private edition is saved.');
$this->assertTrue((bool) $r->published);
$this->assertSame(1, (int) $r->metadata_version, 'A private edit must not change even the public metadata version.');
$this->assertSame('Conteúdo público original.', json_decode($r->editorial_content, true)['body']);
$this->assertSame(2, DB::table('portal_editorial_editions')->where('resource_id', $this->resource)->count());
$this->get('/management/editorial/resources/'.$this->resource)->assertOk()->assertInertia(fn (Assert $p) => $p->where('current', true)->where('canApprove', false)->where('resource.public_edition_no', 1));
$this->get('/management/portal-services/resources/'.$this->resource.'/article/preview')->assertOk()->assertInertia(fn (Assert $p) => $p->where('article.body', 'Nova edição privada.'));
$this->get('/artigos/'.$this->resource)->assertOk()->assertInertia(fn (Assert $p) => $p->where('article.body', 'Conteúdo público original.'));
$this->put('/management/portal-services/resources/'.$this->resource.'/article', ['metadata_version' => 1, 'body' => 'Resposta atrasada.', 'image_mode' => 'none'])->assertConflict();
$this->assertSame(2, DB::table('portal_editorial_editions')->where('resource_id', $this->resource)->count());
$e = app(PortalEditorialWorkflow::class)->latest($this->resource);
$e = $this->action($this->author, $e, 'submit');
$e = $this->action($this->reviewer, $e, 'start_review');
$e = $this->action($this->reviewer, $e, 'approve');
$this->get('/artigos/'.$this->resource)->assertOk()->assertInertia(fn (Assert $p) => $p->where('article.body', 'Conteúdo público original.'));
$this->action($this->publisher, $e, 'publish');
$this->get('/artigos/'.$this->resource)->assertOk()->assertInertia(fn (Assert $p) => $p->where('article.body', 'Nova edição privada.'));
}
public function test_legal_approval_and_publication_are_separate_version_bound_operations(): void
{
$e = $this->save(['title' => 'Legislação revista QA', 'legislation' => true, 'document_type' => 'legislation', 'source_confirmation' => 'official_confirmed']);
$e = $this->action($this->author, $e, 'submit', extra: ['assigned_to' => $this->reviewer->id]);
$e = $this->action($this->reviewer, $e, 'start_review');
$this->action($this->author, $e, 'approve', 403);
$this->action($this->reviewer, $e, 'approve', 422);
$this->action($this->reviewer, $e, 'legal_validate', 403);
$e = $this->action($this->legal, $e, 'legal_validate');
$e = $this->action($this->reviewer, $e, 'approve');
$this->assertSame('Artigo sintético QA', DB::table('portal_resources')->where('id', $this->resource)->value('title'));
$this->assertSame('approved', $e->state);
$approved = $e;
$e = $this->action($this->publisher, $e, 'publish');
$this->assertSame('published', $e->state);
$this->assertSame('Legislação revista QA', DB::table('portal_resources')->where('id', $this->resource)->value('title'));
$this->action($this->publisher, $approved, 'publish', 409);
$draft = $this->save(['title' => 'Alteração posterior privada']);
$this->assertNull($draft->approval);
$this->assertNull($draft->legal_validation);
$this->assertSame('Legislação revista QA', DB::table('portal_resources')->where('id', $this->resource)->value('title'));
$this->actingAsWithMfa($this->author)->getJson('/management/editorial/resources/'.$this->resource)->assertOk()->assertJsonPath('edition.id', $draft->id)->assertJsonPath('resource.published', true);
$this->assertGreaterThan(0, DB::table('portal_editorial_notifications')->where('user_id', $this->author->id)->count());
$this->assertGreaterThan(0, DB::table('audit_events')->where('action', 'editorial_publish')->count());
$this->getJson('/management/editorial/resources/'.$this->resource.'?edition='.$approved->id)->assertOk()->assertJsonPath('current', false)->assertJsonPath('edition.payload.resource.title', 'Legislação revista QA');
}
public function test_replacing_an_attachment_invalidates_approval_and_private_file_routes_are_scoped(): void
{
$doc = $this->document('Anexo novo, apenas na edição privada.');
$e = $this->save(['document_id' => $doc]);
$url = '/management/editorial/resources/'.$this->resource.'/editions/'.$e->id.'/files/'.$doc;
$this->get($url)->assertOk()->assertHeader('Cache-Control', 'no-store, private');
$outsider = User::factory()->create();
$this->actingAsWithMfa($outsider)->getJson($url)->assertForbidden();
$e = $this->action($this->author, $e, 'submit');
$e = $this->action($this->reviewer, $e, 'start_review');
$e = $this->action($this->reviewer, $e, 'approve');
DB::table('documents')->where('id', $doc)->increment('version_no');
$this->action($this->publisher, $e, 'publish', 409);
$this->actingAsWithMfa($this->author)->getJson($url)->assertConflict();
$this->assertNotSame($doc, DB::table('portal_resources')->where('id', $this->resource)->value('document_id'));
}
public function test_returns_rejection_archive_permissions_and_legacy_publication_cannot_bypass_review(): void
{
$e = $this->save(['title' => 'Edição para devolver']);
$e = $this->action($this->author, $e, 'submit');
$e = $this->action($this->reviewer, $e, 'start_review');
$e = $this->action($this->reviewer, $e, 'return');
$this->assertSame('returned', $e->state);
$e = $this->action($this->author, $e, 'submit');
$e = $this->action($this->reviewer, $e, 'start_review');
$e = $this->action($this->reviewer, $e, 'reject');
$e = $this->action($this->author, $e, 'archive');
$this->assertTrue((bool) DB::table('portal_resources')->where('id', $this->resource)->value('published'));
$this->actingAsWithMfa($this->publisher)->post('/management/portal-services/resources/'.$this->resource, ['published' => true])->assertConflict();
$this->actingAs(User::factory()->create())->getJson('/management/editorial')->assertForbidden();
$this->actingAs($this->author)->withSession(['portal.mfa_at' => 0])->postJson('/management/editorial/resources/'.$this->resource.'/editions/'.$e->id, ['action' => 'submit', 'version' => $e->version, 'reason' => 'MFA expirado'])->assertForbidden();
}
public function test_document_metadata_replacement_remains_private_and_is_recovered_with_its_original_file(): void
{
$doc = $this->document('Documento de substituição privado.');
DB::table('documents')->where('id', $doc)->update(['data_classification' => 'internal']);
$fields = ['document_id' => $doc, 'title' => 'Documento revisto QA', 'description' => 'Descrição privada', 'category' => 'guide', 'topic' => 'qa',
'document_type' => 'guide', 'language' => 'pt', 'territorial_scope' => 'national', 'source_confirmation' => 'identified', 'legal_status' => 'not_applicable',
'rights_status' => 'authorized', 'independent_review' => true, 'source_reference' => 'Ensaio sintético de referência documental', 'source_url' => 'https://example.invalid/document',
'legislation' => false, 'essential' => false, 'data_classification' => 'public', 'metadata_version' => 1, 'issuer' => 'Emissor sintético QA'];
$before = DB::table('portal_resources')->where('id', $this->resource)->first();
$this->actingAsWithMfa($this->author)->put('/management/portal-services/resources/'.$this->resource, $fields)->assertSessionHasNoErrors()->assertRedirect();
$this->assertSame($before->document_id, DB::table('portal_resources')->where('id', $this->resource)->value('document_id'));
$this->assertSame('internal', DB::table('documents')->where('id', $doc)->value('data_classification'));
$this->getJson('/management/editorial/resources/'.$this->resource)->assertOk()->assertJsonPath('edition.payload.resource.title', $fields['title'])->assertJsonPath('edition.payload.resource.document_id', $doc);
$this->get('/documentos/'.$this->resource)->assertOk()->assertInertia(fn (Assert $p) => $p->where('resource.title', $before->title));
$e = app(PortalEditorialWorkflow::class)->latest($this->resource);
$e = $this->action($this->author, $e, 'submit');
$e = $this->action($this->reviewer, $e, 'start_review');
$e = $this->action($this->reviewer, $e, 'approve');
$e = $this->action($this->publisher, $e, 'publish');
$this->assertSame('public', DB::table('documents')->where('id', $doc)->value('data_classification'));
$this->assertSame($doc, DB::table('portal_resources')->where('id', $this->resource)->value('document_id'));
$this->assertSame('Emissor sintético QA', DB::table('documents')->where('id', $doc)->value('issuer'));
}
public function test_notice_receipt_never_grants_access_and_revocation_blocks_publication(): void
{
$e = $this->save();
$e = $this->action($this->author, $e, 'submit', extra: ['assigned_to' => $this->reviewer->id]);
$this->actingAsWithMfa($this->reviewer)->getJson('/management/editorial')->assertOk()->assertJsonPath('items.data.0.edition_id', $e->id);
$this->getJson('/management/editorial?state=submitted&kind=article')->assertOk()->assertJsonPath('items.data.0.edition_id', $e->id);
$this->getJson('/management/editorial?state=approved')->assertOk()->assertJsonPath('items.total', 0);
$notice = DB::table('portal_editorial_notifications')->where('user_id', $this->reviewer->id)->value('id');
$this->postJson('/management/editorial/notices/'.$notice.'/read')->assertOk();
$this->assertNotNull(DB::table('portal_editorial_notifications')->where('id', $notice)->value('read_at'));
$this->actingAsWithMfa($this->author)->postJson('/management/editorial/notices/'.$notice.'/read')->assertNotFound();
$outsider = User::factory()->create();
DB::table('portal_editorial_notifications')->insert(['edition_id' => $e->id, 'user_id' => $outsider->id, 'action' => 'submit', 'created_at' => now()]);
$this->actingAsWithMfa($outsider)->getJson('/management/editorial')->assertForbidden();
$this->getJson('/management/editorial/resources/'.$this->resource)->assertForbidden();
$e = $this->action($this->reviewer, $e, 'start_review');
$e = $this->action($this->reviewer, $e, 'approve');
config(['portal.managers' => [$this->author->email, $this->publisher->email, $this->legal->email]]);
$this->action($this->publisher, $e, 'publish', 403);
$this->assertSame('Artigo sintético QA', DB::table('portal_resources')->where('id', $this->resource)->value('title'));
}
public function test_all_contributors_are_excluded_from_independent_approval_and_modified_bytes_block_review(): void
{
$this->save(['description' => 'Contributo deliberado da autora.']);
$this->actingAsWithMfa($this->reviewer);
$e = app(PortalEditorialWorkflow::class)->save($this->reviewer, $this->resource, 2, ['title' => 'Edição colaborativa']);
$e = $this->action($this->reviewer, $e, 'submit');
$e = $this->action($this->legal, $e, 'start_review');
$this->action($this->author, $e, 'approve', 403);
$this->action($this->reviewer, $e, 'approve', 403);
$e = $this->action($this->legal, $e, 'approve');
$this->assertSame('approved', $e->state);
$doc = $this->document('Anexo íntegro inicial.');
$e = $this->save(['document_id' => $doc]);
$key = DB::table('documents')->where('id', $doc)->value('storage_key');
Storage::disk('local')->put($key, 'Alteração dos bytes sem actualizar os metadados.');
$this->action($this->author, $e, 'submit', 404);
$this->getJson('/management/editorial/resources/'.$this->resource)->assertOk()->assertJsonPath('edition.fresh', false);
$this->get('/artigos/'.$this->resource)->assertOk()->assertInertia(fn (Assert $p) => $p->where('article.body', 'Conteúdo público original.'));
}
public function test_new_document_is_prepared_privately_without_disclosure_or_direct_publication(): void
{
$doc = $this->document('Documento novo não publicado.');
DB::table('documents')->where('id', $doc)->update(['data_classification' => 'internal']);
$marker = 'SYNTHEDITORIAL'.Str::random(10);
$this->actingAsWithMfa($this->author)->post('/management/portal-services/resources', ['document_id' => $doc, 'title' => $marker, 'category' => 'guide', 'topic' => 'qa',
'document_type' => 'guide', 'language' => 'pt', 'territorial_scope' => 'national', 'source_confirmation' => 'identified', 'legal_status' => 'not_applicable', 'rights_status' => 'authorized',
'independent_review' => true, 'source_reference' => 'Fonte sintética QA, sem divulgação', 'source_url' => 'https://example.invalid/source', 'legislation' => false, 'essential' => false, 'data_classification' => 'public'])->assertSessionHasNoErrors()->assertRedirect();
$id = DB::table('portal_resources')->where('title', $marker)->value('id');
$this->assertNotNull($id);
$this->assertSame('draft', app(PortalEditorialWorkflow::class)->latest($id)->state);
$this->assertFalse((bool) DB::table('portal_resources')->where('id', $id)->value('published'));
$this->assertSame('internal', DB::table('documents')->where('id', $doc)->value('data_classification'));
$this->get('/documentos/'.$id.'/download')->assertNotFound();
$this->get('/pesquisa?q='.$marker)->assertOk()->assertInertia(fn (Assert $p) => $p->where('results.total', 0));
$this->resource = (int) $id;
$e = app(PortalEditorialWorkflow::class)->latest($this->resource);
$e = $this->action($this->author, $e, 'submit');
$e = $this->action($this->reviewer, $e, 'start_review');
$e = $this->action($this->reviewer, $e, 'approve');
$this->get('/documentos/'.$id.'/download')->assertNotFound();
$this->action($this->publisher, $e, 'publish');
$this->get('/documentos/'.$id.'/download')->assertOk();
}
public function test_migration_refuses_an_operational_target_and_destructive_rollback_with_history(): void
{
$this->save();
$migration = require database_path('migrations/2026_11_13_220000_create_portal_editorial_editions.php');
try {
$migration->down();
$this->fail('Existing editions must not be removed by rollback.');
} catch (\RuntimeException $exception) {
$this->assertStringContainsString('Preserve editions', $exception->getMessage());
}
config(['database.connections.pgsql.port' => 5432]);
try {
$migration->up();
$this->fail('Operational-target schema change must be refused.');
} catch (\RuntimeException $exception) {
$this->assertStringContainsString('only in isolated QA', $exception->getMessage());
} finally {
config(['database.connections.pgsql.port' => 55441]);
}
$this->assertSame(2, DB::table('portal_editorial_editions')->where('resource_id', $this->resource)->count());
$this->assertTrue((bool) DB::table('portal_resources')->where('id', $this->resource)->value('published'));
}
public function test_missing_editorial_schema_blocks_writes_without_blocking_the_existing_dashboard(): void
{
(require database_path('migrations/2026_11_13_220000_create_portal_editorial_editions.php'))->down();
$before = DB::table('portal_resources')->where('id', $this->resource)->first();
$this->actingAsWithMfa($this->author)->get('/dashboard')->assertOk();
$this->putJson('/management/portal-services/resources/'.$this->resource.'/article', ['metadata_version' => 1, 'body' => 'Não aplicar sem estrutura.', 'image_mode' => 'none'])->assertStatus(503);
$this->postJson('/management/portal-services/resources/'.$this->resource, ['published' => true])->assertStatus(503);
$this->assertEquals($before, DB::table('portal_resources')->where('id', $this->resource)->first());
}
public function test_review_independence_fact_is_not_a_policy_and_cannot_be_claimed_by_the_author(): void
{
config(['editorial_workflow.independent_approval.kinds' => [], 'editorial_workflow.independent_approval.resources' => []]);
$e = $this->save(['independent_review' => true]);
$this->assertFalse(app(PortalEditorialWorkflow::class)->requiresIndependent($e));
$this->assertFalse(app(PortalEditorialWorkflow::class)->payload($e)['resource']['independent_review']);
$e = $this->action($this->author, $e, 'submit');
$e = $this->action($this->author, $e, 'start_review');
$e = $this->action($this->author, $e, 'approve');
$this->action($this->author, $e, 'publish');
$this->assertFalse((bool) DB::table('portal_resources')->where('id', $this->resource)->value('independent_review'));
}
}
+25
View File
@@ -0,0 +1,25 @@
<?php
namespace Tests\Unit;
use App\Services\OpportunityReviewMateriality;
use PHPUnit\Framework\TestCase;
final class EditorialLegalVersionTest extends TestCase
{
public function test_legal_decisions_cannot_be_carried_to_a_different_content_version_in_phase_one(): void
{
$service = new OpportunityReviewMateriality;
$before = ['hash' => str_repeat('a', 64), 'payload' => [], 'resources' => []];
$after = [...$before, 'hash' => str_repeat('b', 64)];
$review = ['decision' => 'approved', 'hash' => $before['hash'], 'scope_rule' => OpportunityReviewMateriality::VERSION];
$review['scope_hash'] = $service->hash($before, 'land_legal_review', $review);
$this->assertSame(['land_legal_review'], $service->reconcile($before, $after, ['land_legal_review' => $review], ['land_legal_review'])['retained']);
$strict = $service->reconcile($before, $after, ['land_legal_review' => $review], ['land_legal_review'], exactLegalVersion: true);
$this->assertSame([], $strict['reviews']);
$this->assertSame(['land_legal_review'], $strict['reopened']);
$this->assertFalse($service->matches($after, 'land_legal_review', [...$review, 'accepted_submission_hash' => $after['hash']], true));
$this->assertTrue($service->matches($before, 'land_legal_review', $review, true));
$this->assertSame($before['hash'], $review['hash']);
}
}
+145
View File
@@ -0,0 +1,145 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import { createRequire } from 'node:module';
import React from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import ts from 'typescript';
const require = createRequire(import.meta.url);
const source = fs.readFileSync(
new URL(
'../../resources/js/pages/management/editorial-edition.tsx',
import.meta.url,
),
'utf8',
);
const code = ts.transpileModule(source, {
compilerOptions: {
module: ts.ModuleKind.CommonJS,
target: ts.ScriptTarget.ES2022,
jsx: ts.JsxEmit.ReactJSX,
},
}).outputText;
const module = { exports: {} };
new Function('require', 'module', 'exports', code)(
(name) => {
if (name === '@inertiajs/react')
return {
Link: ({ href, children, ...rest }) =>
React.createElement('a', { href, ...rest }, children),
useForm: (data) => ({
data,
errors: {},
processing: false,
setData() {},
transform() {},
post() {},
}),
};
if (name === './shared')
return {
Management: ({ children }) =>
React.createElement('main', null, children),
Field: ({ label, children }) =>
React.createElement('label', null, label, children),
control: '',
button: '',
};
if (name === './editorial-workflow')
return {
states: {
draft: 'Rascunho',
approved: 'Aprovado',
in_review: 'Em revisão',
},
};
return require(name);
},
module,
module.exports,
);
const Page = module.exports.default;
const props = {
resource: {
id: 91,
title: 'Artigo QA',
published: true,
public_edition_no: 1,
},
manager: true,
legalReviewer: true,
current: true,
canApprove: true,
independentRequired: true,
history: [],
editions: [{ id: 'qa-edition', revision_no: 2, state: 'draft' }],
reviewers: [{ id: 5, name: 'Equipa de revisão' }],
edition: {
id: 'qa-edition',
revision_no: 2,
version: 1,
state: 'draft',
content_hash: 'a'.repeat(64),
fresh: true,
legal_required: false,
approval: null,
legal_validation: null,
payload: {
resource: {
title: 'Novo título',
editorial_content: JSON.stringify({
body: '<script>não executar</script>',
}),
},
files: [
{
id: 'qa-file',
file_name: 'anexo.pdf',
version_no: 2,
sha256: 'b'.repeat(64),
},
],
},
},
};
const render = (changes = {}) =>
renderToStaticMarkup(React.createElement(Page, { ...props, ...changes }));
test('draft preserves public version and offers submission without approval or publication', () => {
const html = render();
assert.match(html, /Versão pública: edição 1/);
assert.match(html, /value="submit"/);
assert.doesNotMatch(html, /value="(?:approve|publish)"/);
assert.match(
html,
/href="\/management\/editorial\/resources\/91\/editions\/qa-edition\/files\/qa-file" target="_blank" rel="noopener noreferrer"/,
);
assert.match(html, /&lt;script&gt;/);
assert.doesNotMatch(html, /<script>/);
});
test('approved edition offers explicit publication; stale files and historical versions cannot be published', () => {
assert.match(
render({
edition: { ...props.edition, state: 'approved', approval: '{}' },
}),
/value="publish"/,
);
const stale = render({
edition: { ...props.edition, state: 'approved', fresh: false },
});
assert.match(stale, /Os anexos mudaram/);
assert.doesNotMatch(stale, /value="publish"/);
const historical = render({ current: false });
assert.match(historical, /Versão histórica para consulta/);
assert.doesNotMatch(historical, /<form/);
assert.match(historical, /edition=qa-edition/);
});
test('contributor does not see independent approval or legal-validation actions', () => {
const html = render({
canApprove: false,
edition: { ...props.edition, state: 'in_review' },
});
assert.doesNotMatch(html, /value="(?:approve|legal_validate)"/);
assert.match(html, /responsável independente/);
});