feat(editorial): preserve public portfolio fiches during private review

This commit is contained in:
2026-11-13 23:23:16 +02:00
parent 1218734eca
commit d2c72f5e13
15 changed files with 678 additions and 74 deletions
@@ -13,6 +13,7 @@ use App\Services\InvestmentGeography;
use App\Services\InvestmentRelations;
use App\Services\InvestmentZones;
use App\Services\PortalAudit;
use App\Services\PortalEditorialWorkflow;
use App\Services\PublicSearchMetrics;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Query\Builder as QueryBuilder;
@@ -99,6 +100,10 @@ class InvestmentPortfolioController extends Controller
{
$management = $request->routeIs('management.portfolio*');
$record = ($management ? InvestmentDossier::query() : self::visible())->findOrFail($id);
$editorial = $management && app(PortalEditorialWorkflow::class)->portfolioEnabled();
if ($editorial) {
$record = app(PortalEditorialWorkflow::class)->workingPortfolio($record);
}
$relatedIds = [];
foreach ([['corridor_projects', 'corridor_id', 'project_id'], ['corridor_clusters', 'corridor_id', 'cluster_id'], ['cluster_projects', 'cluster_id', 'project_id']] as [$table, $left, $right]) {
foreach ([[$left, $right], [$right, $left]] as [$origin, $destination]) {
@@ -147,7 +152,7 @@ class InvestmentPortfolioController extends Controller
->orderByDesc('updated_at')->limit(10)->get(['id', 'version', 'updated_at'])
: collect();
return Inertia::render('portfolio/show', ['complements' => $management ? [] : app(InvestmentComplements::class)->options('portfolio:'.$record->id), 'record' => $record, 'related' => $related, 'management' => $management, 'zones' => $zones,
return Inertia::render('portfolio/show', ['editorialEnabled' => $editorial, 'complements' => $management ? [] : app(InvestmentComplements::class)->options('portfolio:'.$record->id), 'record' => $record, 'related' => $related, 'management' => $management, 'zones' => $zones,
'anchorProject' => $anchorProject, 'existingSubmissions' => $existingSubmissions,
'competitiveness' => $record->cluster_id ? app(ClusterCompetitiveness::class)->publicData($record->cluster_id) : null,
'geography' => app(InvestmentRelations::class)->dossier($record, $management),
@@ -167,6 +172,11 @@ class InvestmentPortfolioController extends Controller
$data = $request->validate(['version_no' => ['required', 'integer', 'min:1'], 'title' => ['required', 'string', 'max:255'], 'source' => ['required', 'string', 'max:2000'],
'content' => ['required', 'array:name,description,location,region,sector,promotion,facts,partners,cta,anchors,gaps,priority,clusters,focus,notice,code'],
'content.*' => ['nullable', 'string', 'max:15000']]);
if (app(PortalEditorialWorkflow::class)->portfolioWrites($dossier->id)) {
app(PortalEditorialWorkflow::class)->savePortfolio($request->user(), $dossier, (int) $data['version_no'], $data);
return back()->with('success', 'Edição privada guardada. A publicação anterior permanece disponível; continue no circuito editorial.');
}
DB::transaction(function () use ($request, $dossier, $data) {
$dossier = InvestmentDossier::whereKey($dossier->id)->lockForUpdate()->firstOrFail();
abort_if($dossier->version_no !== (int) $data['version_no'], 409, 'A ficha foi alterada. Recarregue a página e reveja a versão actual antes de guardar.');
@@ -179,6 +189,7 @@ class InvestmentPortfolioController extends Controller
public function publish(Request $request, InvestmentDossier $dossier): RedirectResponse
{
abort_if(app(PortalEditorialWorkflow::class)->portfolioWrites($dossier->id), 409, 'Use a aprovação e publicação separadas da edição no circuito editorial.');
$data = $request->validate(['version_no' => ['required', 'integer', 'min:1'], 'approval_evidence' => ['required', 'string', 'min:15', 'max:3000']]);
DB::transaction(function () use ($request, $dossier, $data) {
$dossier = InvestmentDossier::whereKey($dossier->id)->lockForUpdate()->firstOrFail();
@@ -40,7 +40,8 @@ final class PortalEditorialWorkflowController extends Controller
}
$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();
$destinations = app(ContentManagement::class)->access($actor);
$data = ['items' => $items, 'filters' => $filters, 'notices' => $workflow->notices($actor), 'resourceAccess' => $access->full($actor) || $access->editorial($actor), 'opportunitiesUrl' => $destinations['opportunities']['index'] ?? null, 'opportunityPending' => $workflow->opportunityPending($actor), 'clustersUrl' => $destinations['clusters']['index'] ?? null];
$data = ['items' => $items, 'filters' => $filters, 'notices' => $workflow->notices($actor), 'resourceAccess' => $access->full($actor) || $access->editorial($actor), 'opportunitiesUrl' => $destinations['opportunities']['index'] ?? null, 'opportunityPending' => $workflow->opportunityPending($actor), 'clustersUrl' => $destinations['clusters']['index'] ?? null,
'portfolioUrl' => $destinations['portfolio']['index'] ?? null, 'portfolioPending' => $workflow->portfolioPending($actor)];
return $this->screen($request, 'management/editorial-workflow', $data);
}
@@ -76,6 +77,34 @@ final class PortalEditorialWorkflowController extends Controller
return $request->expectsJson() ? response()->json(['edition' => $result])->header('Cache-Control', 'private, no-store') : back()->with('success', 'Operação editorial registada.');
}
public function portfolio(Request $request, string $id, PortalEditorialWorkflow $workflow): Response
{
$workflow->authorizePortfolio($request->user(), $id);
$record = DB::table('investment_dossiers')->where('id', $id)->first();
abort_unless($record !== null, 404);
$selected = $request->validate(['edition' => 'nullable|uuid'])['edition'] ?? null;
$latest = $workflow->latestPortfolio($id);
$edition = $selected ? DB::table('portal_editorial_editions')->where('dossier_id', $id)->where('id', $selected)->first() : $latest;
abort_if($selected && ! $edition, 404);
$payload = $edition ? $workflow->payload($edition) : null;
$independent = ! $edition || ! in_array($request->user()->id, $payload['contributors'], true);
return $this->screen($request, 'management/editorial-edition', ['subject' => 'portfolio',
'resource' => ['id' => $id, 'title' => $record->title, 'published' => $record->publication_status === 'published', 'public_edition_no' => $record->publication_status === 'published' ? DB::table('portal_editorial_editions')->where('dossier_id', $id)->where('state', 'published')->max('revision_no') : null],
'edition' => $edition ? [...get_object_vars($edition), 'payload' => $payload, 'fresh' => $edition->state === 'published' || $workflow->fresh($edition)] : null,
'current' => $edition && $edition->id === $latest?->id, 'independentRequired' => true, 'canApprove' => $independent, 'manager' => true, 'legalReviewer' => false,
'reviewers' => User::whereIn('email', config('portal.managers', []))->whereNotNull('email_verified_at')->get(['id', 'name']),
'history' => DB::table('audit_events')->where('source_system', 'document-access-v1')->where('action', 'like', 'editorial_%')->where('after_data->dossier_id', $id)->orderByDesc('id')->get(['id', 'action', 'actor_user_id', 'after_data', 'occurred_at']),
'editions' => DB::table('portal_editorial_editions')->where('dossier_id', $id)->orderByDesc('revision_no')->get(['id', 'revision_no', 'state', 'content_hash', 'author_id', 'created_at'])]);
}
public function portfolioTransition(Request $request, string $id, string $edition, PortalEditorialWorkflow $workflow): Response
{
$result = $workflow->transition($request->user(), $id, $edition, $request->all(), true);
return $request->expectsJson() ? response()->json(['edition' => $result])->header('Cache-Control', 'private, no-store') : back()->with('success', 'Operação editorial da carteira registada.');
}
public function file(Request $request, int $id, string $edition, string $document, PortalEditorialWorkflow $workflow): Response
{
$workflow->authorize($request->user(), $id);
+3
View File
@@ -24,6 +24,9 @@ final class ContentManagement
$editor = $documents->full($actor) || $documents->editorial($actor);
$upload = $editor || $documents->limited($actor);
$result = [];
if (app(PortalEditorialWorkflow::class)->portfolioAvailable() && $actor->can('manage-portal') && ! $profiles->has($actor, 'auditor')) {
$result['portfolio'] = ['index' => '/management/portfolio'];
}
if (app(ClusterCompetitiveness::class)->editorialEnabled() && $actor->can('manage-portal') && ! $profiles->has($actor, 'auditor')) {
$result['clusters'] = ['index' => '/management/clusters'];
}
+176 -17
View File
@@ -2,6 +2,7 @@
namespace App\Services;
use App\Models\InvestmentDossier;
use App\Models\Opportunity;
use App\Models\User;
use Illuminate\Database\Query\Builder;
@@ -56,7 +57,7 @@ final class PortalEditorialWorkflow
{
$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'])));
return hash_equals($e->content_hash, $this->hash($this->refreshPayload($e, $p)));
} catch (HttpException $exception) {
if (in_array($exception->getStatusCode(), [404, 422], true)) {
return false;
@@ -154,7 +155,14 @@ final class PortalEditorialWorkflow
return false;
}
try {
$this->authorize($actor, (int) $edition->resource_id);
if ($edition->dossier_id ?? null) {
if (! config('editorial_workflow.portfolio')) {
return false;
}
$this->authorizePortfolio($actor, $edition->dossier_id);
} else {
$this->authorize($actor, (int) $edition->resource_id);
}
return true;
} catch (HttpException $exception) {
@@ -193,7 +201,7 @@ final class PortalEditorialWorkflow
}
$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];
'href' => $notice->opportunity_id !== null ? '/minhas-oportunidades/'.$notice->opportunity_id : (($edition->dossier_id ?? null) ? '/management/editorial/portfolio/'.$edition->dossier_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;
}
@@ -222,7 +230,7 @@ final class PortalEditorialWorkflow
return null;
}
return ['pending' => $this->queue($actor)->whereNotIn('e.state', ['published', 'archived'])->count() + $this->opportunityPending($actor), 'unread' => $this->unreadCount($actor)];
return ['pending' => $this->queue($actor)->whereNotIn('e.state', ['published', 'archived'])->count() + $this->opportunityPending($actor) + $this->portfolioPending($actor), 'unread' => $this->unreadCount($actor)];
}
/** @param array<string,mixed> $resource
@@ -266,6 +274,141 @@ final class PortalEditorialWorkflow
return hash('sha256', json_encode(Arr::sortRecursive($payload), JSON_THROW_ON_ERROR));
}
public function portfolioEnabled(): bool
{
if (! config('editorial_workflow.portfolio')) {
return false;
}
abort_unless($this->portfolioAvailable(), 503, 'Estrutura editorial da carteira ainda não instalada.');
return true;
}
public function portfolioAvailable(): bool
{
return (bool) config('editorial_workflow.portfolio') && $this->available() && Schema::hasColumn('portal_editorial_editions', 'dossier_id');
}
public function portfolioPending(User $actor): int
{
if (! $this->portfolioAvailable() || ! $actor->can('manage-portal') || app(PortalProfileAccess::class)->has($actor, 'auditor')) {
return 0;
}
return DB::table('portal_editorial_editions as e')->join('investment_dossiers as d', 'd.id', '=', 'e.dossier_id')->whereNull('d.deleted_at')
->whereNotIn('e.state', ['published', 'archived'])->whereNotExists(fn ($q) => $q->selectRaw('1')->from('portal_editorial_editions as later')->whereColumn('later.dossier_id', 'e.dossier_id')->whereColumn('later.revision_no', '>', 'e.revision_no'))->count();
}
public function portfolioWrites(string $id): bool
{
if ($this->portfolioEnabled()) {
return true;
}
abort_if(Schema::hasColumn('portal_editorial_editions', 'dossier_id') && DB::table('portal_editorial_editions')->where('dossier_id', $id)->exists(), 503, 'Reactive o circuito editorial para editar esta ficha; a publicação anterior está preservada.');
return false;
}
public function authorizePortfolio(User $actor, string $id, bool $write = false): void
{
abort_unless($this->portfolioEnabled(), 503);
abort_unless($actor->can('manage-portal') && ! app(PortalProfileAccess::class)->has($actor, 'auditor'), 403);
abort_unless(DB::table('investment_dossiers')->where('id', $id)->whereNull('deleted_at')->exists(), 404);
if ($write) {
abort_unless(app(PortalProfileAccess::class)->mfa($actor), 403, 'Confirme o MFA na sessão existente.');
}
}
public function latestPortfolio(string $id): ?\stdClass
{
return DB::table('portal_editorial_editions')->where('dossier_id', $id)->orderByDesc('revision_no')->first();
}
public function workingPortfolio(InvestmentDossier $record): InvestmentDossier
{
$edition = $this->latestPortfolio($record->id);
if (! $edition) {
return $record;
}
$copy = clone $record;
$fields = $this->payload($edition)['resource'];
$copy->forceFill(Arr::only($fields, ['title', 'source', 'content']));
$copy->setAttribute('version_no', max((int) $record->getAttribute('version_no'), (int) $edition->revision_no));
return $copy;
}
/** @param array<string,mixed> $fields
* @param list<int> $contributors
* @return EditorialPayload
*/
private function portfolioSnapshot(array $fields, array $contributors): array
{
if (is_string($fields['content'])) {
$fields['content'] = json_decode($fields['content'], true, 512, JSON_THROW_ON_ERROR);
}
$links = [];
foreach (['project_id' => 'projects', 'cluster_id' => 'economic_clusters', 'corridor_id' => 'development_corridors'] as $key => $table) {
if (! empty($fields[$key])) {
$context = DB::table($table)->where('id', $fields[$key])->whereNull('deleted_at')->first();
abort_unless($context !== null, 422, 'Contexto da ficha indisponível.');
$links[] = ['target_type' => $table, 'target_key' => $fields[$key], 'context' => Arr::only(get_object_vars($context), ['id', 'name', 'code', 'sector', 'status', 'description', 'primary_territory_id', 'territory_version', 'budget_amount', 'budget_currency', 'start_date', 'end_date'])];
}
}
abort_unless(count($links) === 1, 422, 'Contexto único da ficha necessário.');
return ['resource' => $fields, 'links' => $links, 'files' => [], 'document_changes' => [], 'contributors' => $contributors, 'policy' => ['independent_required' => true]];
}
/** @param EditorialPayload $payload
* @return EditorialPayload
*/
private function refreshPayload(\stdClass $edition, array $payload): array
{
return ($edition->dossier_id ?? null) ? $this->portfolioSnapshot($payload['resource'], $payload['contributors'])
: $this->snapshot($payload['resource'], $payload['links'], $payload['document_changes'], $payload['contributors'], $payload['policy']);
}
/** @param array<string,mixed> $changes */
public function savePortfolio(User $actor, InvestmentDossier $record, int $expected, array $changes): \stdClass
{
$this->authorizePortfolio($actor, $record->id, true);
return DB::transaction(function () use ($actor, $record, $expected, $changes) {
$record = InvestmentDossier::whereKey($record->id)->lockForUpdate()->firstOrFail();
abort_unless((int) $this->workingPortfolio($record)->getAttribute('version_no') === $expected, 409, 'A edição mudou. Recupere a versão actual antes de guardar.');
$previous = $this->latestPortfolio($record->id);
$public = $this->portfolioSnapshot($record->getAttributes(), []);
if (! $previous && $record->getAttribute('publication_status') === 'published') {
DB::table('portal_editorial_editions')->insert(['id' => (string) Str::uuid(), 'dossier_id' => $record->id, 'revision_no' => $expected,
'state' => 'published', 'version' => 1, 'payload' => json_encode($public, JSON_THROW_ON_ERROR), 'content_hash' => $this->hash($public),
'author_id' => $record->editor_id ?? $actor->id, 'legal_required' => false, 'published_at' => $record->getAttribute('published_at'), 'created_at' => now(), 'updated_at' => now()]);
}
$base = $previous ? $this->payload($previous) : $public;
$fields = array_replace($base['resource'], Arr::only($changes, ['title', 'source', 'content']), ['version_no' => $expected + 1]);
$contributors = array_values(array_unique([...($previous && $previous->state !== 'published' ? $base['contributors'] : []), $actor->id]));
$snapshot = $this->portfolioSnapshot($fields, $contributors);
$id = (string) Str::uuid();
DB::table('portal_editorial_editions')->insert(['id' => $id, 'dossier_id' => $record->id, 'revision_no' => $expected + 1,
'state' => 'draft', 'version' => 1, 'payload' => json_encode($snapshot, JSON_THROW_ON_ERROR), 'content_hash' => $this->hash($snapshot),
'author_id' => $actor->id, 'legal_required' => false, '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 da carteira guardada; publicação preservada.');
return $edition;
});
}
/** @param EditorialPayload $payload */
private function publishPortfolio(\stdClass $record, \stdClass $edition, array $payload): void
{
$approval = json_decode($edition->approval, true, 512, JSON_THROW_ON_ERROR);
$fields = Arr::only($payload['resource'], ['title', 'source', 'content']);
$fields['content'] = json_encode($fields['content'], JSON_THROW_ON_ERROR);
DB::table('investment_dossiers')->where('id', $record->id)->update([...$fields, 'version_no' => max((int) $record->version_no + 1, (int) $edition->revision_no),
'editor_id' => $edition->author_id, 'reviewer_id' => $approval['actor_id'], 'approval_basis' => 'manager', 'approval_evidence' => $approval['reason'], 'publication_status' => 'published', 'published_at' => now(), 'updated_at' => now()]);
}
/** @param array<string,mixed> $changes
* @param list<array<string,mixed>>|null $links
*/
@@ -311,16 +454,22 @@ final class PortalEditorialWorkflow
}
/** @param array<string,mixed> $input */
public function transition(User $actor, int $resource, string $id, array $input): \stdClass
public function transition(User $actor, int|string $resource, string $id, array $input, bool $portfolio = false): \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();
if ($portfolio) {
$this->authorizePortfolio($actor, (string) $resource, true);
} else {
$this->authorize($actor, (int) $resource, true);
}
$reason = $portfolio && in_array($input['action'] ?? '', ['approve', 'publish'], true) ? 'required|string|min:15|max:3000' : 'required|string|min:3|max:5000';
$data = validator($input, ['version' => 'required|integer|min:1', 'action' => 'required|in:submit,start_review,return,reject,legal_validate,approve,publish,archive', 'reason' => $reason, '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();
return DB::transaction(function () use ($actor, $resource, $id, $data, $portfolio) {
$r = DB::table($portfolio ? 'investment_dossiers' : 'portal_resources')->where('id', $resource)->lockForUpdate()->first();
$e = DB::table('portal_editorial_editions')->where('id', $id)->where($portfolio ? 'dossier_id' : '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.');
$latest = $portfolio ? $this->latestPortfolio((string) $resource) : $this->latest((int) $resource);
abort_unless((int) $e->version === (int) $data['version'] && $latest?->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.');
@@ -335,11 +484,11 @@ final class PortalEditorialWorkflow
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.');
if (in_array($action, ['submit', 'approve', 'legal_validate', 'publish'], true)) {
abort_unless(hash_equals($e->content_hash, $this->hash($this->refreshPayload($e, $payload))), 409, 'Conteúdo, contexto 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)) {
if ((in_array($action, ['approve', 'legal_validate'], true) || ($portfolio && $action === 'publish')) && $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)) {
@@ -362,16 +511,25 @@ final class PortalEditorialWorkflow
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);
if ($portfolio) {
$this->publishPortfolio($r, $e, $payload);
} else {
$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.');
abort_if($portfolio && ! $responsible->can('manage-portal'), 422, 'Responsável sem autoridade sobre a carteira.');
$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()]);
if ($portfolio) {
DB::table('investment_dossiers')->where('id', $resource)->update(['publication_status' => 'draft', 'version_no' => (int) $r->version_no + 1, 'updated_at' => now()]);
} else {
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',
@@ -396,6 +554,7 @@ final class PortalEditorialWorkflow
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.');
abort_if(($e->dossier_id ?? null) && ! $actor->can('manage-portal'), 403, 'Autoridade sobre a carteira revogada.');
if ($legal) {
abort_unless(in_array($actor->id, config('editorial_workflow.legal_reviewers', []), true), 403, 'Designação jurídica revogada.');
}
@@ -438,7 +597,7 @@ final class PortalEditorialWorkflow
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,
app(DocumentManagementAccess::class)->audit($actor, 'editorial_'.$action, ['resource_id' => $e->resource_id, 'dossier_id' => $e->dossier_id ?? null, '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]);
}
}
+1
View File
@@ -4,6 +4,7 @@ return [
// Development/QA opt-in. Installation and activation in an operational target are separate decisions.
'enabled' => env('PORTAL_EDITORIAL_WORKFLOW_ENABLED', false),
'cluster_competitiveness' => env('PORTAL_EDITORIAL_CLUSTER_WORKFLOW_ENABLED', false),
'portfolio' => env('PORTAL_EDITORIAL_PORTFOLIO_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".
@@ -0,0 +1,37 @@
<?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_editions', function (Blueprint $t): void {
$t->unsignedBigInteger('resource_id')->nullable()->change();
$t->foreignUuid('dossier_id')->nullable()->constrained('investment_dossiers')->restrictOnDelete();
$t->unique(['dossier_id', 'revision_no']);
});
DB::statement('ALTER TABLE portal_editorial_editions ADD CONSTRAINT portal_editorial_edition_subject CHECK (num_nonnulls(resource_id,dossier_id)=1)');
}
public function down(): void
{
if (! IsolatedQaDatabase::allowsSchemaChanges() || DB::table('portal_editorial_editions')->whereNotNull('dossier_id')->exists()) {
throw new RuntimeException('Preserve portfolio editorial history.');
}
DB::statement('ALTER TABLE portal_editorial_editions DROP CONSTRAINT portal_editorial_edition_subject');
Schema::table('portal_editorial_editions', function (Blueprint $t): void {
$t->dropUnique(['dossier_id', 'revision_no']);
$t->dropForeign(['dossier_id']);
$t->dropColumn('dossier_id');
$t->unsignedBigInteger('resource_id')->nullable(false)->change();
});
}
};
@@ -1,3 +1,17 @@
## 13/11/2026 — fichas da carteira integradas no mesmo circuito e pacote editorial
**Lacuna corrigida em QA:** guardar uma ficha de projecto âncora, cluster ou corredor deixava de a disponibilizar ao público. Com `PORTAL_EDITORIAL_PORTFOLIO_ENABLED` activo, o editor existente guarda uma edição privada e conserva integralmente o registo público, incluindo título, conteúdo, fonte, versão e datas. A confirmação independente continua obrigatória; aprovar e publicar são operações distintas. A publicação anterior só é substituída pela publicação explícita da edição aprovada. Arquivar uma edição privada preserva-a; arquivar a edição pública actual retira-a da consulta, sem apagar dados ou históricos.
**Reutilização:** mesmas tabelas, decisões, transições, auditoria, responsáveis, notificações e ecrã do circuito Fase 1. A migração aditiva `2026_11_13_230000_extend_editorial_editions_to_portfolio` permite que uma edição pertença a um recurso documental OU a uma ficha da carteira, com chaves estrangeiras, unicidade da revisão e exclusividade do sujeito. Não foi criado outro motor nem um segundo dossier. A estrutura foi instalada exclusivamente em `editorial_phase1_20261113_test:55441`; recibo em `outputs/editorial-portfolio-20261113/qa-installation.json`, com as contagens das 177 tabelas anteriores preservadas, excepto o incremento do livro de migrações. A reversão recusa remover histórico da carteira existente.
**Percurso:** Gestão de conteúdos → Carteira → abrir ficha → guardar alteração → «Conferir versões, submeter, aprovar e publicar no circuito editorial». O ecrã mostra a edição exacta, a fonte e o contexto identificado, conteúdo, versões, decisão e histórico. Submeter com responsável, iniciar revisão, aprovar e publicar separadamente. O painel indica as fichas pendentes; as notificações ligam à edição concreta. Os gestores usam as autorizações existentes; todas as escritas exigem MFA válido. API/JSON e prévias verificam a autoridade no servidor. Leitura privada usa `private, no-store`; a gestão da carteira reutiliza também o histórico Inertia cifrado.
**Integridade:** o hash liga a edição ao seu conteúdo e contexto de projecto/cluster/corredor, incluindo dados relevantes da entidade. Alterações do contexto ou da edição exigem nova aprovação. Todos os colaboradores privados são excluídos da aprovação e publicação dessa edição, preservando a independência já exigida na carteira. A publicação reconfere a autoridade do aprovador. Tentativas pela antiga operação combinada são recusadas. Desactivar o módulo não permite contornar o circuito para editar fichas com histórico novo. Um contexto desactualizado impede aprovar/publicar, mas permite devolver/rejeitar para correcção, sem prender a revisão. Versões anteriores, razões e dados deliberados ficam conservados.
**Provas:** 5 testes novos/199 asserções; regressão Fase 1 11/223; carteira anterior 8/201; notificações nativas de oportunidades 1/20; frontend 20 testes. PHPStan focado, tipos e compilação aprovados. Provas em `outputs/editorial-portfolio-20261113/`, incluindo `integration-final.log`, `phase1-regression-final.log`, `native-regression.log`, `opportunity-notices-regression.log`, `frontend-final.log`, `analysis-final.log`, `types-final.log` e `build.log`. Os testes de carteira anteriores usam a base nativa própria e descartável; os novos usam transacções revertidas. Não foram submetidos processos reais, alteradas permissões ou publicados conteúdos reais.
**Entrega e saldo:** este avanço integra a candidata agrupada, preservando os pacotes anteriores. A instalação operacional continua por executar e inclui agora três migrações da Fase 1/carteira; as migrações só autorizam QA e o executor Git não executa SQL no destino. É necessário preparar/autorizar a aplicação nominal do schema e contrato, backup/restauro e instalação conjunta pelo processo existente. A conferência visual permanece pendente, sem invalidar as provas aceites. Este recorte versiona as fichas públicas da carteira; a edição dos dados-base de projectos/clusters, relações, estatística, indicadores demográficos e conteúdos territoriais ainda não fica encerrada, nem as notificações próprias de Competitividade. Nenhuma instalação ou activação em homologação/produção foi efectuada.
## 13/11/2026 — pacote agrupado do Dashboard Colaborativo e revisão de Competitividade
**Decisão do proprietário:** agrupar melhorias e funcionalidades relacionadas para uma instalação conjunta quando o conjunto estiver pronto. O pacote desta passagem reúne a Fase 1 e o primeiro recorte editorial da Fase 2: análise de Competitividade dos clusters. Não representa o fecho de toda a Fase 2 nem autorização para publicar conteúdos.
@@ -11130,3 +11130,4 @@ REL-DOSSIER-20261113,"Agrupar no dossier correspondente os relatórios produzido
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
ACT-20261113-EDITORIAL-CLUSTERS,"Separar aprovação/publicação da análise de Competitividade; preservar publicação, contexto, versões e autoridade; agrupar entrega relacionada","Directiva de continuidade e decisão de pacote conjunto do proprietário, 13/11/2026; Fase 2 já delimitada",Dashboard Colaborativo / Competitividade dos clusters,/management/clusters; /management/clusters/{id}/competitiveness/{action}; Gestão de conteúdos; Circuito editorial,"Extensão do circuito cifrado existente: submissão, revisão, aprovação e publicação distintas; devolução/rejeição/arquivo; versões/contexto; MFA e autoridade efectiva; navegação reutilizada",QA isolado; flag própria desactivada por defeito; sem instalação operacional,"outputs/editorial-clusters-20261113: integração 14/252; regressão Fase 1 11/223; frontend 18; PHPStan, tipos e build aprovados",Recorte implementado em QA; não encerra a Fase 2,Conferência visual; notificações dos clusters; versionamento dos dados-base e outros conteúdos Fase 2; preparação/instalação operacional,Instalação agrupada exige preparar e autorizar schema/contrato da Fase 1 no destino; não bloqueia QA,Aprovar não publica; rascunho/recuperação preservam publicação; alteração do contexto invalida aprovação; independência quando exigida; revogação e conflitos bloqueados; retirar publicação preserva revisão privada,Alta,,,,,Editor e lista existentes com estados distintos; entradas de gestão integradas,"JSON autenticado, MFA e autorizações verificadas no servidor",Histórico cifrado existente; sem novas tabelas ou migrações deste recorte,Histórico recuperado como nova edição; publicação anterior preservada,Publicação só por confirmação distinta após aprovação da edição exacta,,,,Provas sintéticas do delta e regressão afectada; provas anteriores reutilizadas,Conferência visual; notificações clusters e restantes conteúdos Fase 2; instalação conjunta,"ClusterCompetitiveness, audit_events cifrados, gestão de estruturas, PortalProfileAccess, ContentManagement e circuito Fase 1",,Agrupar funcionalidades relacionadas num pacote e instalar em conjunto quando prontas; sem intervenção operacional nesta passagem,Implementação localizada sem migração; pacote Git agrupado,,outputs/editorial-clusters-20261113; manual unificado actualizado,Dashboard Colaborativo — pacote conjunto,13/11/2026,,,,,,,,Regressão anterior de Competitividade e Fase 1; oportunidades mantêm provas aceites,"Ciclo editorial de Competitividade, preservação pública, permissões, independência, recuperação e contexto","Sem conferência visual ou instalação operacional; não prova versão editorial dos dados-base, notificações clusters nem restantes conteúdos Fase 2",,Circuito editorial e agrupamento de entregas,Submeter → rever → aprovar → publicar; preservar a publicação e autoridade efectiva,QA demonstrado; entrega agrupada preparada; instalação pendente
ACT-20261113-EDITORIAL-CARTEIRA,Preservar fichas públicas de projecto âncora/cluster/corredor enquanto novas versões seguem o circuito editorial; integrar pacote conjunto,"Directiva de continuidade e decisão de agrupar funcionalidades relacionadas, 13/11/2026",Dashboard Colaborativo / carteira de investimento,/management/portfolio/{id}; /management/editorial/portfolio/{id}; transições/notificações existentes,Edições privadas na mesma tabela da Fase 1; aprovação/publicação separadas; fonte/contexto por hash; independência de todos os colaboradores; MFA e auditoria; pendências e notificações,Estrutura e provas apenas em QA 55441; flag própria desactivada por defeito; sem instalação operacional,outputs/editorial-portfolio-20261113: novos 5/199; Fase 1 11/223; carteira anterior 8/201; notificações oportunidades 1/20; frontend 20; PHPStan/tipos/build aprovados,Fichas da carteira implementadas em QA; dados-base e restante Fase 2 pendentes,Conferência visual; preparação/instalação operacional; versionamento de dados-base e restantes conteúdos Fase 2,Schema/contrato operacional da entrega agrupada exige preparação e autorização concreta; não bloqueia QA,"Guardar/aprovar mantém todos os campos públicos; publicar aplica só edição aprovada; independência, revogação, MFA e versões verificadas no servidor; contexto alterado exige nova decisão; histórico e notificações recuperáveis",Alta,,,,,"Editor existente, ecrã de revisão reutilizado, versões/contexto, acesso pelo painel e Gestão de conteúdos",JSON/HTML protegidos pelas mesmas autorizações; leitura privada sem cache,Edições e decisões na tabela comum; migração QA aditiva com exclusividade do sujeito e rollback protegido,Rascunho e versões anteriores recuperados sem substituir publicação; edições deliberadas preservadas,Ficha pública mantida; nova publicação só por operação independente explícita,,,,Cinco percursos de integração novos e regressões afectadas aprovados; não equivale ao fecho do módulo de dados-base,"Dados-base, relações, estatística/demografia/território; conferência visual e instalação conjunta","PortalEditorialWorkflow, portal_editorial_editions, investment_dossiers, auditoria, perfis/MFA, editor e prévias nativos",,Implementar em QA e agrupar com Fase 1/Competitividade; sem intervenção operacional,Integração aditiva no circuito existente; sem segundo dossier ou motor,,Recibo e provas em outputs/editorial-portfolio-20261113; manual unificado actualizado,Dashboard Colaborativo — pacote conjunto,13/11/2026,,,,,,,,"Regressão Fase 1, carteira anterior e notificações nativas; Competitividade mantém provas anteriores aplicáveis","Preservação pública, versões/contexto, independência, MFA, autoridade, histórico/notificações e bloqueio de bypass",Sem revisão visual ou instalação operacional; não prova versionamento dos dados-base nem restantes conteúdos Fase 2,,Carteira e continuidade do circuito editorial,Ficha existente → edição privada → submissão → revisão → aprovação → publicação; consulta pública e recuperação preservadas,QA demonstrado; integrado na candidata agrupada; instalação pendente
Can't render this file because it is too large.
@@ -3,6 +3,7 @@ import { Frame, card, button } from '@/pages/investment/shared';
import { states } from './shared';
type Access = {
portfolio?: { index: string };
clusters?: { index: string };
editorial?: { index: string };
procurement?: { index: string; create: string };
@@ -28,6 +29,20 @@ export default function ContentHub({
acção respeita a sua autorização actual.
</p>
<div className="grid gap-4 md:grid-cols-2">
{access.portfolio && (
<section className={card}>
<h2 className="text-xl font-semibold">
Carteira de investimento
</h2>
<p>
Rever versões das fichas de projectos, clusters e
corredores, preservando a publicação anterior.
</p>
<Link className={button} href={access.portfolio.index}>
Gerir fichas e revisões da carteira
</Link>
</section>
)}
{access.clusters && (
<section className={card}>
<h2 className="text-xl font-semibold">
@@ -1,6 +1,7 @@
import { Link, useForm } from '@inertiajs/react';
import { Management, Field, button, control } from './shared';
import { states } from './editorial-workflow';
import { labels as portfolioLabels } from '../portfolio/shared';
type Edition = {
id: string;
@@ -15,6 +16,8 @@ type Edition = {
title: string;
description?: string;
editorial_content?: string | null;
source?: string;
content?: Record<string, string | null>;
};
files: {
id: string;
@@ -22,6 +25,11 @@ type Edition = {
version_no: number;
sha256: string;
}[];
links?: {
target_type: string;
target_key: string;
context?: { name?: string; code?: string; sector?: string };
}[];
};
approval: string | null;
legal_validation: string | null;
@@ -37,9 +45,11 @@ export default function EditorialEdition({
current,
canApprove,
independentRequired,
subject = 'resource',
}: {
subject?: 'resource' | 'portfolio';
resource: {
id: number;
id: number | string;
title: string;
published: boolean;
public_edition_no?: number | null;
@@ -60,6 +70,7 @@ export default function EditorialEdition({
canApprove: boolean;
independentRequired: boolean;
}) {
const editionBase = `/management/editorial/${subject === 'portfolio' ? 'portfolio' : 'resources'}/${resource.id}`;
const form = useForm({
version: edition?.version ?? 1,
action: '',
@@ -92,15 +103,31 @@ export default function EditorialEdition({
<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>
{subject === 'portfolio' ? (
<Link
className="underline"
href={`/management/portfolio/${resource.id}`}
>
Alterar ficha da carteira
</Link>
) : (
<>
<Link
className="underline"
href={`/management/portal-services/resources/${resource.id}/article`}
>
Alterar artigo
</Link>
</>
)}
{subject === 'resource' && (
<Link
className="underline"
href="/management/portal-services"
>
Alterar metadados/documento
</Link>
)}
</div>
<p>
{resource.published
@@ -126,13 +153,44 @@ export default function EditorialEdition({
<p className="text-sm break-all">
Identificador da edição: {edition.id}
<br />
Hash do conteúdo e anexos: {edition.content_hash}
Hash do conteúdo e{' '}
{subject === 'portfolio' ? 'contexto' : '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>
{subject === 'portfolio' && (
<>
<p>Fonte: {edition.payload.resource.source}</p>
<dl>
{Object.entries(
edition.payload.resource.content ?? {},
).map(([key, value]) => (
<div key={key}>
<dt className="font-semibold">
{portfolioLabels[key] ?? key}
</dt>
<dd className="whitespace-pre-wrap">
{value}
</dd>
</div>
))}
</dl>
</>
)}
{subject === 'portfolio' &&
edition.payload.links?.map((link) => (
<p key={link.target_key}>
Contexto: {link.context?.name} ·{' '}
{link.context?.code}
{link.context?.sector
? ` · ${link.context.sector}`
: ''}
</p>
))}
{edition.payload.resource.editorial_content && (
<p className="whitespace-pre-wrap">
{
@@ -193,7 +251,7 @@ export default function EditorialEdition({
assigned_to: data.assigned_to || null,
}));
form.post(
`/management/editorial/resources/${resource.id}/editions/${edition.id}`,
`${editionBase}/editions/${edition.id}`,
{
onSuccess: () =>
form.reset('action', 'reason'),
@@ -225,8 +283,16 @@ export default function EditorialEdition({
canApprove)) &&
(action !== 'approve' ||
canApprove) &&
(subject !== 'portfolio' ||
action !== 'publish' ||
canApprove) &&
(edition.fresh ||
action === 'archive'),
[
'archive',
'return',
'reject',
'start_review',
].includes(action)),
)
.map((action) => (
<option key={action} value={action}>
@@ -286,10 +352,7 @@ export default function EditorialEdition({
) : (
<p>
Versão histórica para consulta.{' '}
<Link
className="underline"
href={`/management/editorial/resources/${resource.id}`}
>
<Link className="underline" href={editionBase}>
Voltar à edição actual
</Link>
</p>
@@ -307,7 +370,7 @@ export default function EditorialEdition({
<li key={e.id}>
<Link
className="underline"
href={`/management/editorial/resources/${resource.id}?edition=${e.id}`}
href={`${editionBase}?edition=${e.id}`}
>
Edição {e.revision_no}: {states[e.state] ?? e.state}
</Link>
@@ -38,11 +38,15 @@ export default function EditorialWorkflow({
opportunitiesUrl,
opportunityPending,
clustersUrl,
portfolioUrl,
portfolioPending = 0,
}: {
resourceAccess: boolean;
opportunitiesUrl: string | null;
opportunityPending: number;
clustersUrl?: string | null;
portfolioUrl?: string | null;
portfolioPending?: number;
filters?: { state?: string; kind?: string };
items: { data: Item[]; current_page: number; last_page: number };
notices: {
@@ -63,6 +67,11 @@ export default function EditorialWorkflow({
próprio.
</p>
<div className="flex flex-wrap gap-4">
{portfolioUrl && (
<Link className="underline" href={portfolioUrl}>
Carteira: {portfolioPending} fichas pendentes
</Link>
)}
{clustersUrl && (
<Link className="underline" href={clustersUrl}>
Competitividade dos clusters
+51 -35
View File
@@ -29,6 +29,7 @@ export default function Show(props: Props) {
}
type Props = {
editorialEnabled?: boolean;
competitiveness?: ApprovedCompetitiveness | null;
complements?: ComplementOption[];
geography: StructureGeography;
@@ -63,6 +64,7 @@ type Props = {
};
function Editor({
editorialEnabled = false,
competitiveness,
complements = [],
record,
@@ -423,43 +425,57 @@ function Editor({
Guardar rascunho
</button>
</form>
<form
className="space-y-3 rounded border p-4"
onSubmit={(e) => {
e.preventDefault();
approval.post(`${base}/${record.id}/publish`);
}}
>
<p>
O gestor autorizado pode aprovar e publicar esta
ficha. Registe a referência da sua decisão.
{editorialEnabled ? (
<p className="rounded border p-4">
A edição privada preserva a ficha pública anterior.{' '}
<Link
className="underline"
href={`/management/editorial/portfolio/${record.id}`}
>
Conferir versões, submeter, aprovar e publicar
no circuito editorial
</Link>
.
</p>
<Field label="Referência da autorização e revisão">
<textarea
className={control}
required
minLength={15}
value={approval.data.approval_evidence}
onChange={(e) =>
approval.setData(
'approval_evidence',
e.target.value,
)
}
/>
</Field>
{approval.errors.approval_evidence && (
<p role="alert">
{approval.errors.approval_evidence}
</p>
)}
<button
className={button}
disabled={approval.processing || form.isDirty}
) : (
<form
className="space-y-3 rounded border p-4"
onSubmit={(e) => {
e.preventDefault();
approval.post(`${base}/${record.id}/publish`);
}}
>
Validar e publicar
</button>
</form>
<p>
O gestor autorizado pode aprovar e publicar esta
ficha. Registe a referência da sua decisão.
</p>
<Field label="Referência da autorização e revisão">
<textarea
className={control}
required
minLength={15}
value={approval.data.approval_evidence}
onChange={(e) =>
approval.setData(
'approval_evidence',
e.target.value,
)
}
/>
</Field>
{approval.errors.approval_evidence && (
<p role="alert">
{approval.errors.approval_evidence}
</p>
)}
<button
className={button}
disabled={approval.processing || form.isDirty}
>
Validar e publicar
</button>
</form>
)}
</>
) : (
<>
+4 -2
View File
@@ -101,6 +101,8 @@ Route::middleware(['auth', 'verified'])->group(function () {
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('/management/editorial/portfolio/{id}', [PortalEditorialWorkflowController::class, 'portfolio'])->whereUuid('id');
Route::post('/management/editorial/portfolio/{id}/editions/{edition}', [PortalEditorialWorkflowController::class, 'portfolioTransition'])->whereUuid(['id', 'edition']);
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');
@@ -305,13 +307,13 @@ Route::middleware(['auth', 'verified', EnsureScopedPortalAdministration::class])
Route::get('demands/{id}/analysis', [EducationController::class, 'analysis'])->whereUuid('id')->name('analysis');
Route::post('demands/{id}/investment', [EducationInvestmentController::class, 'store'])->whereUuid('id')->name('investment');
});
Route::get('portfolio', [InvestmentPortfolioController::class, 'index'])->name('portfolio.index');
Route::get('portfolio', [InvestmentPortfolioController::class, 'index'])->middleware(PrivateInvestmentResponse::class)->name('portfolio.index');
Route::get('relationship-repair', [InvestmentRelationshipRepairController::class, 'preview'])->name('relationship-repair.preview');
Route::get('opportunity-origin', [OpportunityOriginReviewController::class, 'index'])->name('opportunity-origin.index');
Route::post('opportunity-origin/preview', [OpportunityOriginReviewController::class, 'preview'])->name('opportunity-origin.preview');
Route::post('opportunity-origin/apply', [OpportunityOriginReviewController::class, 'apply'])->name('opportunity-origin.apply');
Route::post('relationship-repair', [InvestmentRelationshipRepairController::class, 'apply'])->name('relationship-repair.apply');
Route::get('portfolio/{id}', [InvestmentPortfolioController::class, 'show'])->whereUuid('id')->name('portfolio.show');
Route::get('portfolio/{id}', [InvestmentPortfolioController::class, 'show'])->whereUuid('id')->middleware(PrivateInvestmentResponse::class)->name('portfolio.show');
Route::put('portfolio/{dossier}', [InvestmentPortfolioController::class, 'save'])->name('portfolio.save');
Route::post('portfolio/{dossier}/publish', [InvestmentPortfolioController::class, 'publish'])->name('portfolio.publish');
Route::get('clusters/{cluster}/competitiveness', [ClusterCompetitivenessController::class, 'show'])->name('clusters.competitiveness.show');
@@ -0,0 +1,183 @@
<?php
namespace Tests\Feature;
use App\Models\DevelopmentCorridor;
use App\Models\EconomicCluster;
use App\Models\InvestmentDossier;
use App\Models\Project;
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 Inertia\Testing\AssertableInertia as Assert;
use Tests\Support\AuthenticatesQaActors;
use Tests\TestCase;
final class PortfolioEditorialWorkflowTest extends TestCase
{
use AuthenticatesQaActors, DatabaseTransactions;
private User $author;
private User $reviewer;
private InvestmentDossier $dossier;
protected function setUp(): void
{
parent::setUp();
$this->assertTrue(IsolatedQaDatabase::allowsSchemaChanges());
if (! Schema::hasColumn('portal_editorial_editions', 'dossier_id')) {
(require database_path('migrations/2026_11_13_230000_extend_editorial_editions_to_portfolio.php'))->up();
}
$this->withoutVite();
[$this->author, $this->reviewer] = User::factory()->count(2)->create()->all();
config(['portal.managers' => [$this->author->email, $this->reviewer->email], 'portal.profile_access_enabled' => false,
'editorial_workflow.enabled' => true, 'editorial_workflow.portfolio' => true]);
$this->makeDossier('project');
}
private function makeDossier(string $kind): void
{
$model = match ($kind) {
'project' => Project::class, 'cluster' => EconomicCluster::class, 'corridor' => DevelopmentCorridor::class,
};
$entity = $model::create(['code' => 'ED-QA-'.bin2hex(random_bytes(6)), 'name' => 'Contexto sintético', 'status' => 'active', ...($kind === 'cluster' ? ['sector' => 'Energia'] : [])]);
$this->dossier = InvestmentDossier::create([$kind.'_id' => $entity->id, 'title' => 'Ficha pública sintética '.$kind, 'source' => 'Fonte sintética QA', 'content' => ['description' => 'Texto público original'],
'version_no' => 1, 'publication_status' => 'published', 'approval_basis' => 'document_owner', 'approval_evidence' => 'Publicação sintética aceite em QA', 'published_at' => now()]);
}
private function edit(string $text = 'Alteração privada deliberada', int $status = 302, ?int $expected = null): ?\stdClass
{
$this->actingAsWithMfa($this->author);
$record = app(PortalEditorialWorkflow::class)->workingPortfolio($this->dossier->fresh());
$this->put('/management/portfolio/'.$record->id, ['version_no' => $expected ?? $record->version_no, 'title' => 'Novo título privado', 'source' => 'Fonte sintética revista', 'content' => ['description' => $text]])->assertStatus($status);
return app(PortalEditorialWorkflow::class)->latestPortfolio($record->id);
}
private function action(User $actor, \stdClass $edition, string $action, int $status = 200, array $extra = []): ?\stdClass
{
$this->actingAsWithMfa($actor);
$this->postJson('/management/editorial/portfolio/'.$this->dossier->id.'/editions/'.$edition->id,
['version' => $edition->version, 'action' => $action, 'reason' => 'Decisão fundamentada de demonstração em QA', ...$extra])->assertStatus($status);
return DB::table('portal_editorial_editions')->where('id', $edition->id)->first();
}
private function approve(\stdClass $edition): \stdClass
{
$edition = $this->action($this->author, $edition, 'submit', 200, ['assigned_to' => $this->reviewer->id]);
$edition = $this->action($this->reviewer, $edition, 'start_review');
return $this->action($this->reviewer, $edition, 'approve');
}
public function test_public_project_cluster_and_corridor_fiches_survive_private_edit_review_and_approval(): void
{
foreach (['project', 'cluster', 'corridor'] as $kind) {
if ($kind !== 'project') {
$this->makeDossier($kind);
}
$before = $this->dossier->fresh()->getAttributes();
$edition = $this->edit();
$this->assertSame($before, $this->dossier->fresh()->getAttributes());
$this->get('/carteira/'.$this->dossier->id)->assertOk()->assertInertia(fn (Assert $p) => $p->where('record.content.description', 'Texto público original'));
$this->get('/management/portfolio/'.$this->dossier->id)->assertOk()->assertHeader('Cache-Control', 'no-store, private')->assertInertia(fn (Assert $p) => $p->where('record.content.description', 'Alteração privada deliberada')->where('editorialEnabled', true));
$this->edit('Edição obsoleta', 409, 1);
$edition = $this->approve($edition);
$this->assertSame($before, $this->dossier->fresh()->getAttributes());
$this->action($this->author, $edition, 'publish', 403);
$this->action($this->reviewer, $edition, 'publish');
$this->get('/carteira/'.$this->dossier->id)->assertInertia(fn (Assert $p) => $p->where('record.content.description', 'Alteração privada deliberada'));
$this->assertSame($this->dossier->id, $this->dossier->fresh()->id);
$this->assertSame($this->reviewer->id, $this->dossier->fresh()->reviewer_id);
if ($kind === 'corridor') {
$published = app(PortalEditorialWorkflow::class)->latestPortfolio($this->dossier->id);
$this->action($this->reviewer, $published, 'archive');
$this->get('/carteira/'.$this->dossier->id)->assertNotFound();
}
}
}
public function test_changed_context_and_edits_reset_decisions_and_old_publication_remains(): void
{
$edition = $this->approve($this->edit());
Project::whereKey($this->dossier->project_id)->update(['name' => 'Contexto revisto']);
$this->getJson('/management/editorial/portfolio/'.$this->dossier->id)->assertJsonPath('edition.fresh', false);
$this->action($this->reviewer, $edition, 'publish', 409);
$new = $this->edit('Nova edição após alteração do contexto');
$this->assertNull($new->approval);
$this->assertNull($new->legal_validation);
$this->action($this->reviewer, $edition, 'publish', 409);
$this->get('/carteira/'.$this->dossier->id)->assertInertia(fn (Assert $p) => $p->where('record.content.description', 'Texto público original'));
$this->getJson('/management/editorial/portfolio/'.$this->dossier->id.'?edition='.$edition->id)->assertJsonPath('current', false)->assertJsonPath('edition.id', $edition->id);
}
public function test_permissions_api_notice_scope_and_legacy_bypass_fail_closed(): void
{
$edition = $this->edit();
$this->post('/management/portfolio/'.$this->dossier->id.'/publish', ['version_no' => 1, 'approval_evidence' => 'Tentativa legada sintética'])->assertConflict();
$edition = $this->action($this->author, $edition, 'submit', 200, ['assigned_to' => $this->reviewer->id]);
$this->actingAsWithMfa($this->reviewer);
$this->getJson('/management/editorial')->assertOk()->assertJsonPath('portfolioUrl', '/management/portfolio')->assertJsonPath('portfolioPending', 1)
->assertJsonPath('notices.0.href', '/management/editorial/portfolio/'.$this->dossier->id.'?edition='.$edition->id);
$this->get('/gestao-conteudos')->assertInertia(fn (Assert $p) => $p->where('access.portfolio.index', '/management/portfolio'));
$other = User::factory()->create();
$this->actingAs($other)->getJson('/management/editorial/portfolio/'.$this->dossier->id)->assertForbidden();
$this->action($other, $edition, 'start_review', 403);
$this->actingAsWithMfa($this->reviewer);
session()->forget('portal.mfa_at');
$this->postJson('/management/editorial/portfolio/'.$this->dossier->id.'/editions/'.$edition->id, ['version' => $edition->version, 'action' => 'start_review', 'reason' => 'Motivo sintético QA'])->assertForbidden();
config(['editorial_workflow.portfolio' => false]);
$this->actingAsWithMfa($this->author);
$this->put('/management/portfolio/'.$this->dossier->id, ['version_no' => 1, 'title' => 'Não escrever', 'source' => 'Fonte QA', 'content' => ['description' => 'Não escrever']])->assertStatus(503);
$this->get('/carteira/'.$this->dossier->id)->assertOk();
$this->actingAsWithMfa($this->reviewer);
$this->getJson('/management/editorial')->assertJsonCount(0, 'notices');
}
public function test_revoked_reviewer_and_foreign_edition_cannot_publish_and_private_archive_preserves_public(): void
{
$firstId = $this->dossier->id;
$edition = $this->approve($this->edit());
$this->makeDossier('cluster');
$this->action($this->reviewer, $edition, 'publish', 404);
$this->getJson('/management/editorial/portfolio/'.$this->dossier->id.'?edition='.$edition->id)->assertNotFound();
$this->dossier = InvestmentDossier::findOrFail($firstId);
config(['portal.managers' => [$this->author->email]]);
// The author cannot publish, and a different authorised publisher must recheck the old reviewer.
$publisher = User::factory()->create();
config(['portal.managers' => [$this->author->email, $publisher->email]]);
$this->action($publisher, $edition, 'publish', 403);
$this->action($this->author, $edition, 'archive');
$this->get('/carteira/'.$this->dossier->id)->assertOk();
$this->assertSame('Texto público original', $this->dossier->fresh()->content['description']);
}
public function test_every_private_contributor_is_excluded_and_portfolio_history_cannot_be_removed(): void
{
$this->edit();
$this->actingAsWithMfa($this->reviewer);
$this->put('/management/portfolio/'.$this->dossier->id, ['version_no' => 2, 'title' => 'Colaboração privada', 'source' => 'Fonte sintética', 'content' => ['description' => 'Revisão deliberada pelo colaborador']])->assertRedirect();
$edition = app(PortalEditorialWorkflow::class)->latestPortfolio($this->dossier->id);
$edition = $this->action($this->author, $edition, 'submit');
$edition = $this->action($this->reviewer, $edition, 'start_review');
$this->action($this->reviewer, $edition, 'approve', 403);
$this->action($this->author, $edition, 'approve', 403);
Project::whereKey($this->dossier->project_id)->update(['name' => 'Contexto alterado; é necessária correcção']);
$this->action($this->reviewer, $edition, 'return');
$this->get('/management/portfolio/'.$this->dossier->id)->assertInertia(fn (Assert $p) => $p->where('record.content.description', 'Revisão deliberada pelo colaborador'));
try {
(require database_path('migrations/2026_11_13_230000_extend_editorial_editions_to_portfolio.php'))->down();
$this->fail('Histórico não vazio não pode ser eliminado.');
} catch (\RuntimeException $exception) {
$this->assertStringContainsString('Preserve portfolio', $exception->getMessage());
}
$this->assertTrue(Schema::hasColumn('portal_editorial_editions', 'dossier_id'));
$this->assertSame(3, DB::table('portal_editorial_editions')->where('dossier_id', $this->dossier->id)->count());
}
}
+61
View File
@@ -24,6 +24,8 @@ const code = ts.transpileModule(source, {
const module = { exports: {} };
new Function('require', 'module', 'exports', code)(
(name) => {
if (name === '../portfolio/shared')
return { labels: { description: 'Visão e proposta de valor' } };
if (name === '@inertiajs/react')
return {
Link: ({ href, children, ...rest }) =>
@@ -130,6 +132,11 @@ test('approved edition offers explicit publication; stale files and historical v
});
assert.match(stale, /Os anexos mudaram/);
assert.doesNotMatch(stale, /value="publish"/);
const staleReview = render({
edition: { ...props.edition, state: 'in_review', fresh: false },
});
assert.match(staleReview, /value="return"/);
assert.doesNotMatch(staleReview, /value="approve"/);
const historical = render({ current: false });
assert.match(historical, /Versão histórica para consulta/);
assert.doesNotMatch(historical, /<form/);
@@ -143,3 +150,57 @@ test('contributor does not see independent approval or legal-validation actions'
assert.doesNotMatch(html, /value="(?:approve|legal_validate)"/);
assert.match(html, /responsável independente/);
});
test('portfolio edition reuses review screen with its own context, history and edit routes', () => {
const html = render({
subject: 'portfolio',
resource: { ...props.resource, id: 'qa-dossier' },
edition: {
...props.edition,
payload: {
...props.edition.payload,
resource: {
title: 'Ficha privada',
source: 'Fonte QA',
content: { description: '<script>não executar</script>' },
},
files: [],
links: [
{
target_type: 'projects',
target_key: 'qa-project',
context: { name: 'Projecto QA', code: 'QA-1' },
},
],
},
},
});
assert.match(html, /href="\/management\/portfolio\/qa-dossier"/);
assert.match(
html,
/\/management\/editorial\/portfolio\/qa-dossier\?edition=qa-edition/,
);
assert.match(html, /Visão e proposta de valor/);
assert.match(html, /Contexto: Projecto QA/);
assert.doesNotMatch(html, /Alterar artigo|Alterar metadados\/documento/);
assert.match(html, /&lt;script&gt;/);
});
test('portfolio contributor cannot select publication of an approved edition', () => {
assert.doesNotMatch(
render({
subject: 'portfolio',
canApprove: false,
edition: { ...props.edition, state: 'approved' },
}),
/value="publish"/,
);
assert.match(
render({
subject: 'portfolio',
canApprove: true,
edition: { ...props.edition, state: 'approved' },
}),
/value="publish"/,
);
});