From 1218734ecafa70811fa36970ad688073455597ab Mon Sep 17 00:00:00 2001 From: nala Date: Fri, 13 Nov 2026 23:08:02 +0200 Subject: [PATCH] feat(editorial): separate cluster analysis approval and publication in QA --- .../ClusterCompetitivenessController.php | 2 +- .../InvestmentStructureController.php | 13 +- .../PortalEditorialWorkflowController.php | 4 +- app/Services/ClusterCompetitiveness.php | 111 ++++++++- app/Services/ContentManagement.php | 3 + config/editorial_workflow.php | 1 + .../unification/traceability-and-manual.md | 14 ++ .../v2/auditoria-portal/matriz-requisitos.csv | 1 + resources/js/pages/management/content-hub.tsx | 16 ++ .../pages/management/editorial-workflow.tsx | 7 + .../js/pages/structures/competitiveness.tsx | 213 +++++++++++++++--- resources/js/pages/structures/editor.tsx | 1 + resources/js/pages/structures/index.tsx | 15 +- routes/web.php | 2 +- .../Feature/ClusterEditorialWorkflowTest.php | 189 ++++++++++++++++ tests/frontend/cluster-editorial.test.mjs | 112 +++++++++ 16 files changed, 658 insertions(+), 46 deletions(-) create mode 100644 tests/Feature/ClusterEditorialWorkflowTest.php create mode 100644 tests/frontend/cluster-editorial.test.mjs diff --git a/app/Http/Controllers/ClusterCompetitivenessController.php b/app/Http/Controllers/ClusterCompetitivenessController.php index 92b859b2..ab8ae2f5 100644 --- a/app/Http/Controllers/ClusterCompetitivenessController.php +++ b/app/Http/Controllers/ClusterCompetitivenessController.php @@ -19,7 +19,7 @@ final class ClusterCompetitivenessController extends Controller $data = $request->validate(['expected_version' => 'required|integer|min:0', 'draft' => $action === 'saved' ? 'required|array:version,clusterId,sector,region,forces' : 'prohibited', 'revision' => $action === 'restored' ? 'required|integer|min:1' : 'prohibited', - 'reason' => in_array($action, ['approved', 'withdrawn'], true) ? 'required|string|min:10|max:3000' : 'nullable|string|max:3000']); + 'reason' => in_array($action, ['saved', 'restored'], true) ? 'nullable|string|max:3000' : 'required|string|min:10|max:3000']); return response()->json($service->change($cluster, $request->user()->id, (int) $data['expected_version'], $action, $data)) ->header('Cache-Control', 'private, no-store'); diff --git a/app/Http/Controllers/InvestmentStructureController.php b/app/Http/Controllers/InvestmentStructureController.php index 2a339888..01b10f3d 100644 --- a/app/Http/Controllers/InvestmentStructureController.php +++ b/app/Http/Controllers/InvestmentStructureController.php @@ -31,8 +31,17 @@ class InvestmentStructureController extends Controller }); } - return Inertia::render('structures/index', ['structure' => $structure, 'filters' => $filters, - 'records' => $query->orderBy('name')->orderBy('id')->paginate(20)->withQueryString()]); + $records = $query->orderBy('name')->orderBy('id')->paginate(20)->withQueryString(); + if ($structure === 'clusters' && app(ClusterCompetitiveness::class)->editorialEnabled()) { + $records->through(function ($record) { + $analysis = app(ClusterCompetitiveness::class)->current($record->id); + $record->setAttribute('editorial_state', $analysis['workflow']['state'] ?? ($analysis['approved'] ? 'legacy_published' : 'not_started')); + + return $record; + }); + } + + return Inertia::render('structures/index', ['structure' => $structure, 'filters' => $filters, 'records' => $records]); } public function edit(Request $request, string $structure, ?string $id = null): Response diff --git a/app/Http/Controllers/PortalEditorialWorkflowController.php b/app/Http/Controllers/PortalEditorialWorkflowController.php index 12e01942..11d4ece5 100644 --- a/app/Http/Controllers/PortalEditorialWorkflowController.php +++ b/app/Http/Controllers/PortalEditorialWorkflowController.php @@ -39,8 +39,8 @@ final class PortalEditorialWorkflowController extends Controller $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)]; + $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]; return $this->screen($request, 'management/editorial-workflow', $data); } diff --git a/app/Services/ClusterCompetitiveness.php b/app/Services/ClusterCompetitiveness.php index a1d11353..a02f7a00 100644 --- a/app/Services/ClusterCompetitiveness.php +++ b/app/Services/ClusterCompetitiveness.php @@ -3,7 +3,9 @@ namespace App\Services; use App\Models\EconomicCluster; +use App\Models\User; use Illuminate\Database\Query\Builder; +use Illuminate\Support\Arr; use Illuminate\Support\Facades\Crypt; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Validator; @@ -46,6 +48,12 @@ final class ClusterCompetitiveness public function management(string $cluster): array { $state = $this->current($cluster); + $state['editorial_enabled'] = $this->editorialEnabled(); + if (isset($state['workflow'])) { + $record = EconomicCluster::findOrFail($cluster); + $state['workflow']['fresh'] = hash_equals($state['workflow']['hash'], $this->editionHash($record, $state['draft'])); + $state['workflow']['independent_required'] = $this->independentRequired($state['workflow']); + } $state['history'] = $this->events($cluster)->orderByRaw("(after_data->>'version')::integer DESC")->limit(50)->get() ->map(fn ($event) => ['version' => json_decode($event->after_data, true)['version'], 'action' => $event->action, 'at' => $event->occurred_at, 'actor' => $event->actor_user_id])->all(); @@ -106,12 +114,17 @@ final class ClusterCompetitiveness */ public function change(EconomicCluster $cluster, int $actor, int $expected, string $action, array $input = []): array { + abort_if(! in_array($action, ['saved', 'restored', 'approved', 'withdrawn'], true) && ! $this->editorialEnabled(), 503, 'Circuito editorial dos clusters ainda não activado.'); + return DB::transaction(function () use ($cluster, $actor, $expected, $action, $input) { - EconomicCluster::whereKey($cluster->id)->lockForUpdate()->firstOrFail(); + $cluster = EconomicCluster::whereKey($cluster->id)->lockForUpdate()->firstOrFail(); $before = $this->current($cluster->id); abort_if($before['version'] !== $expected, 409, 'A análise foi alterada. Conserve os campos actuais e compare a versão do servidor antes de voltar a guardar.'); $state = $before; - if ($action === 'saved') { + if ($this->editorialEnabled() || isset($before['workflow'])) { + abort_unless($this->editorialEnabled(), 503, 'Reactive o circuito editorial para continuar esta análise; o histórico e a publicação foram conservados.'); + $state = $this->editorialChange($cluster, $actor, $action, $input, $before); + } elseif ($action === 'saved') { $state['draft'] = $this->validateDraft($input['draft'], $cluster->id); } elseif ($action === 'restored') { $event = $this->events($cluster->id)->where('after_data->version', $input['revision'])->first(); @@ -144,9 +157,101 @@ final class ClusterCompetitiveness */ public function publicData(string $cluster): ?array { - $approved = $this->current($cluster)['approved']; + $state = $this->current($cluster); + $approved = isset($state['workflow']) ? $state['published'] : $state['approved']; // Explicit projection: never return editor identity, approval evidence, history or the unapproved draft. return $approved ? ['draft' => $approved['draft'], 'version' => $approved['version'], 'at' => $approved['at']] : null; } + + public function editorialEnabled(): bool + { + return (bool) config('editorial_workflow.cluster_competitiveness'); + } + + /** @param array|null $draft */ + private function editionHash(EconomicCluster $cluster, ?array $draft): string + { + $context = Arr::only($cluster->getAttributes(), ['id', 'code', 'name', 'sector', 'status', 'primary_territory_id', 'territory_version']); + + return hash('sha256', json_encode(Arr::sortRecursive(['context' => $context, 'draft' => $draft]), JSON_THROW_ON_ERROR)); + } + + /** @param array $input + * @param array $state + * @return array + */ + private function editorialChange(EconomicCluster $cluster, int $actorId, string $action, array $input, array $state): array + { + $actor = User::whereKey($actorId)->firstOrFail(); + abort_unless($actor->can('manage-portal') && ! app(PortalProfileAccess::class)->has($actor, 'auditor'), 403); + abort_unless(app(PortalProfileAccess::class)->mfa($actor), 403, 'Confirme o MFA na sessão existente.'); + if (! isset($state['workflow'])) { + abort_unless(in_array($action, ['saved', 'restored', 'withdrawn'], true), 409, 'Guarde uma edição privada antes de iniciar o novo circuito.'); + // An accepted legacy publication remains public; no retrospective approval is invented. + $state['published'] = $state['approved']; + $state['approved'] = null; + $state['workflow'] = ['state' => 'draft', 'edition' => $state['version'], 'contributors' => [], 'approval' => null, + 'hash' => $this->editionHash($cluster, $state['draft']), 'independent_required' => false]; + } + $workflow = $state['workflow']; + if (in_array($action, ['saved', 'restored'], true)) { + if ($action === 'saved') { + $state['draft'] = $this->validateDraft($input['draft'], $cluster->id); + $restoredContributors = []; + } else { + $event = $this->events($cluster->id)->where('after_data->version', $input['revision'])->first(); + abort_unless($event !== null, 404); + $restored = $this->decode($event); + $state['draft'] = $restored['draft']; + $restoredContributors = $restored['workflow']['contributors'] ?? [$event->actor_user_id]; + } + $state['approved'] = null; + $state['workflow'] = ['state' => 'draft', 'edition' => $state['version'] + 1, + 'contributors' => array_values(array_unique([...($workflow['state'] === 'published' ? [] : $workflow['contributors']), ...$restoredContributors, $actorId])), + 'hash' => $this->editionHash($cluster, $state['draft']), 'approval' => null, + 'independent_required' => $this->independentRequired($workflow)]; + + return $state; + } + validator($input, ['reason' => 'required|string|min:10|max:3000'])->validate(); + $allowed = ['submitted' => ['draft', 'returned'], 'review_started' => ['submitted'], 'returned' => ['in_review'], 'rejected' => ['in_review'], + 'approved' => ['in_review'], 'published' => ['approved'], 'archived' => ['draft', 'submitted', 'in_review', 'returned', 'rejected', 'approved', 'published'], 'withdrawn' => ['draft', 'submitted', 'in_review', 'returned', 'rejected', 'approved', 'published', 'archived']]; + abort_unless(isset($allowed[$action]) && in_array($workflow['state'], $allowed[$action], true), 409, 'Transição incompatível com o estado da edição.'); + if (in_array($action, ['submitted', 'approved', 'published'], true)) { + abort_unless(hash_equals($workflow['hash'], $this->editionHash($cluster, $state['draft'])), 409, 'O contexto do cluster mudou. Guarde nova edição e volte a conferir.'); + } + if ($action === 'submitted' || $action === 'approved') { + $this->assertComplete($state['draft']); + } + if ($action === 'approved') { + abort_if($this->independentRequired($workflow) && in_array($actorId, $workflow['contributors'], true), 403, 'Aprovação independente necessária para esta edição.'); + $workflow['approval'] = ['actor' => $actorId, 'edition' => $workflow['edition'], 'hash' => $workflow['hash'], 'at' => now()->toISOString(), 'reason' => $input['reason']]; + $state['approved'] = ['draft' => $state['draft'], 'at' => now()->toISOString(), 'actor' => $actorId, 'version' => $state['version'] + 1, 'evidence' => $input['reason']]; + } + if ($action === 'published') { + $decision = $workflow['approval']; + abort_unless($decision && $decision['edition'] === $workflow['edition'] && hash_equals($decision['hash'], $workflow['hash']), 422, 'Aprovação desta edição necessária.'); + $reviewer = User::whereKey($decision['actor'])->first(); + abort_unless($reviewer && $reviewer->can('manage-portal') && ! app(PortalProfileAccess::class)->has($reviewer, 'auditor'), 403, 'Autoridade da aprovação revogada.'); + abort_if($this->independentRequired($workflow) && in_array($reviewer->id, $workflow['contributors'], true), 403, 'Aprovação independente necessária.'); + $state['published'] = [...$state['approved'], 'at' => now()->toISOString(), 'version' => $state['version'] + 1]; + } + if ($action === 'withdrawn' || ($action === 'archived' && $workflow['state'] === 'published')) { + $state['published'] = null; + } + $workflow['state'] = match ($action) { + 'submitted' => 'submitted', 'review_started' => 'in_review', 'returned' => 'returned', 'rejected' => 'rejected', + 'approved' => 'approved', 'published' => 'published', 'archived' => 'archived', 'withdrawn' => $workflow['state'] === 'published' ? 'archived' : $workflow['state'], + }; + $state['workflow'] = $workflow; + + return $state; + } + + /** @param array $workflow */ + private function independentRequired(array $workflow): bool + { + return ($workflow['independent_required'] ?? false) || in_array('cluster_competitiveness', config('editorial_workflow.independent_approval.kinds', []), true); + } } diff --git a/app/Services/ContentManagement.php b/app/Services/ContentManagement.php index e0b7f532..ba46107a 100644 --- a/app/Services/ContentManagement.php +++ b/app/Services/ContentManagement.php @@ -24,6 +24,9 @@ final class ContentManagement $editor = $documents->full($actor) || $documents->editorial($actor); $upload = $editor || $documents->limited($actor); $result = []; + if (app(ClusterCompetitiveness::class)->editorialEnabled() && $actor->can('manage-portal') && ! $profiles->has($actor, 'auditor')) { + $result['clusters'] = ['index' => '/management/clusters']; + } if (app(Procurement::class)->available() && app(Procurement::class)->access($actor)) { $result['procurement'] = ['index' => '/management/concursos', 'create' => '/management/concursos/novo']; } diff --git a/config/editorial_workflow.php b/config/editorial_workflow.php index dc1e754f..140256db 100644 --- a/config/editorial_workflow.php +++ b/config/editorial_workflow.php @@ -3,6 +3,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), // 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". diff --git a/docs/investmoz/unification/traceability-and-manual.md b/docs/investmoz/unification/traceability-and-manual.md index 0800c419..1d11708f 100644 --- a/docs/investmoz/unification/traceability-and-manual.md +++ b/docs/investmoz/unification/traceability-and-manual.md @@ -1,3 +1,17 @@ +## 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. + +**Disponível em desenvolvimento/QA:** o editor existente dos clusters permite guardar → submeter → iniciar revisão → aprovar → publicar, com devolução, rejeição, arquivo e recuperação do histórico. A aprovação deixa de publicar quando `PORTAL_EDITORIAL_CLUSTER_WORKFLOW_ENABLED` estiver activo. Uma análise anteriormente pública continua disponível ao activar o novo circuito, guardar, rever, recuperar ou arquivar um rascunho. Publicar substitui-a explicitamente; retirar a publicação não cancela uma revisão privada em curso. Estados editoriais e estado operacional do cluster aparecem separados na lista existente, acessível pela Gestão de conteúdos/Circuito editorial. + +**Versões e autoridade:** o hash vincula o conteúdo das cinco forças e o contexto identificado do cluster (nome, código, sector, estado e território). Alterações posteriores exigem guardar nova edição e obter nova aprovação. Conflitos devolvem 409 sem sobrescrever texto. A política independente já configurável aceita o tipo `cluster_competitiveness` e impede aprovação por qualquer colaborador da edição quando exigida. O publicador reconfere a autoridade actual do aprovador. As escritas verificam gestão existente, exclusão de auditor e MFA da sessão no servidor. O histórico continua no mecanismo cifrado e aditivo existente; não há migração nem novo motor de Competitividade. A exposição pública conserva apenas análise publicada, versão e data, sem decisões privadas/identidades. Desactivar o circuito conserva a publicação e impede retomar por uma operação legada que contorne as decisões. + +**Provas aceites:** 14 testes de integração de clusters/252 asserções, incluindo os sete testes anteriores; regressão Fase 1 11/223; frontend 18 testes. PHPStan focado, tipos e compilação aprovados. Provas em `outputs/editorial-clusters-20261113/`. As provas anteriores de oportunidades e anexos são reutilizadas, sem repetição. Dados sintéticos em transacções revertidas na base própria de QA 55441; sem alteração operacional, novas concessões ou publicação real. + +**Pacote/instalação:** bundle Git conjunto a partir da base `2f1ef2fd`, manifesto com hashes e provas, preservando o bundle anterior. O executor continua a ser `deploy/homologacao/pipeline.py`; não foi alterado. Antes de instalar: confirmar revisão/base e delta, backup recuperável e ensaio de restauro isolado; tratar expressamente as duas migrações aditivas da Fase 1 e o contrato nominal de schema do destino. O executor Git não executa SQL no destino e as migrações actuais só autorizam QA: não basta enviar este pacote a `main`. Após instalação autorizada, conferir a revisão efectivamente servida, acessos e os percursos afectados, sem republicar conteúdo. Reversão pelo executor existente preserva dados; não usar rollback destrutivo de tabelas com histórico. Não foi obtido novo backup remoto nem executada instalação nesta passagem. + +**Saldo concreto:** Competitividade tem agora aprovação/publicação separadas demonstradas em QA. Conferência visual, preparação operacional do schema/contrato, configuração explícita de designações e instalação conjunta continuam pendentes. Versionamento editorial dos dados-base de projectos âncora/clusters, estatísticas, indicadores demográficos e conteúdos territoriais continua por implementar; notificações editoriais dos clusters não são encerradas por este recorte. A Fase 1 continua identificada separadamente no mesmo saldo. Sem alterações a dados demográficos, instituições ou restantes entregas aceites. + ## 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. diff --git a/docs/investmoz/v2/auditoria-portal/matriz-requisitos.csv b/docs/investmoz/v2/auditoria-portal/matriz-requisitos.csv index 20addee9..1f8fd210 100644 --- a/docs/investmoz/v2/auditoria-portal/matriz-requisitos.csv +++ b/docs/investmoz/v2/auditoria-portal/matriz-requisitos.csv @@ -11129,3 +11129,4 @@ INE-DEMO-01,"Integração nacional de dados demográficos INE na viabilidade exi 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 +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 diff --git a/resources/js/pages/management/content-hub.tsx b/resources/js/pages/management/content-hub.tsx index 59501277..e430b940 100644 --- a/resources/js/pages/management/content-hub.tsx +++ b/resources/js/pages/management/content-hub.tsx @@ -3,6 +3,7 @@ import { Frame, card, button } from '@/pages/investment/shared'; import { states } from './shared'; type Access = { + clusters?: { index: string }; editorial?: { index: string }; procurement?: { index: string; create: string }; opportunities?: { @@ -27,6 +28,21 @@ export default function ContentHub({ acção respeita a sua autorização actual.

+ {access.clusters && ( +
+

+ Competitividade dos clusters +

+

+ Preparar a análise, submeter, rever e aprovar uma + edição. Publicar é uma operação separada e preserva + a análise anterior até à confirmação. +

+ + Gerir análises dos clusters + +
+ )} {access.editorial && (

diff --git a/resources/js/pages/management/editorial-workflow.tsx b/resources/js/pages/management/editorial-workflow.tsx index 215e3efa..d4f8b428 100644 --- a/resources/js/pages/management/editorial-workflow.tsx +++ b/resources/js/pages/management/editorial-workflow.tsx @@ -37,10 +37,12 @@ export default function EditorialWorkflow({ resourceAccess, opportunitiesUrl, opportunityPending, + clustersUrl, }: { resourceAccess: boolean; opportunitiesUrl: string | null; opportunityPending: number; + clustersUrl?: string | null; filters?: { state?: string; kind?: string }; items: { data: Item[]; current_page: number; last_page: number }; notices: { @@ -61,6 +63,11 @@ export default function EditorialWorkflow({ próprio.

+ {clustersUrl && ( + + Competitividade dos clusters + + )} Painel geral diff --git a/resources/js/pages/structures/competitiveness.tsx b/resources/js/pages/structures/competitiveness.tsx index cc0cdef3..a5ee9a0d 100644 --- a/resources/js/pages/structures/competitiveness.tsx +++ b/resources/js/pages/structures/competitiveness.tsx @@ -14,6 +14,7 @@ import type { Force, } from '@/lib/cluster-competitiveness'; import { button, control } from '../management/shared'; +import { states as editorialStates } from '../management/editorial-workflow'; const labels = { evidence: 'Observações e evidências locais', @@ -33,22 +34,67 @@ export type CompetitivenessState = { at: string; } | null; history: { version: number; action: string; at: string; actor: number }[]; + editorial_enabled?: boolean; + workflow?: { + state: string; + edition: number; + hash: string; + fresh: boolean; + contributors: number[]; + independent_required: boolean; + }; + published?: CompetitivenessState['approved']; }; const actions: Record = { saved: 'Gravação', - approved: 'Aprovação pública', + approved: 'Aprovação', withdrawn: 'Retirada da aprovação', restored: 'Recuperação do histórico', + submitted: 'Submissão', + review_started: 'Início da revisão', + returned: 'Devolução', + rejected: 'Rejeição', + published: 'Publicação', + archived: 'Arquivo', +}; +const editorialActions: Record = { + draft: [ + ['submitted', 'Submeter para revisão'], + ['archived', 'Arquivar rascunho'], + ], + returned: [ + ['submitted', 'Voltar a submeter'], + ['archived', 'Arquivar rascunho'], + ], + submitted: [ + ['review_started', 'Iniciar revisão'], + ['archived', 'Arquivar edição'], + ], + in_review: [ + ['returned', 'Devolver para correcção'], + ['rejected', 'Rejeitar com fundamento'], + ['approved', 'Aprovar edição'], + ['archived', 'Arquivar edição'], + ], + approved: [ + ['published', 'Publicar edição aprovada'], + ['archived', 'Arquivar edição'], + ], + rejected: [['archived', 'Arquivar edição']], + published: [['archived', 'Arquivar e retirar publicação']], + archived: [], }; export default function Competitiveness({ clusterId, sector, initial, + viewerId, }: { clusterId: string; sector: string; initial?: CompetitivenessState | null; + viewerId?: number; }) { const [draft, setDraft] = useState( () => initial?.draft ?? newCompetitivenessDraft(clusterId, sector), @@ -69,6 +115,8 @@ export default function Competitiveness({ server.draft ?? newCompetitivenessDraft(clusterId, sector), ); const endpoint = `/management/clusters/${clusterId}/competitiveness`; + const editorial = server.editorial_enabled || !!server.workflow; + const publicVersion = server.workflow ? server.published : server.approved; function errorMessage(error: unknown) { if (error instanceof PortalApiError) { if (error.status === 409) @@ -164,11 +212,36 @@ export default function Competitiveness({ {dirty ? 'Existem alterações por guardar.' : 'Campos correspondem à versão recuperada.'}{' '} - {server.approved - ? `Consulta pública: versão aprovada ${server.approved.version}; novas edições conservam essa versão até outra aprovação.` + {publicVersion + ? `Consulta pública: versão ${publicVersion.version}; novas edições conservam essa versão até outra publicação ou retirada explícita.` : 'Sem análise aprovada para consulta pública.'}

-
+ {editorial && ( +

+ Aprovar e publicar são operações distintas. Estado da + edição: {editorialStates[server.workflow?.state ?? 'draft']} + . A publicação anterior mantém-se durante a revisão. +

+ )} + {server.workflow && !server.workflow.fresh && ( +

+ O contexto do cluster mudou. Guarde uma nova edição e volte + a conferir antes de aprovar ou publicar. +

+ )} + {server.workflow && !server.editorial_enabled && ( +

+ O circuito está desactivado neste ambiente. A publicação e o + histórico foram preservados; a edição está temporariamente + só para consulta. +

+ )} +
diff --git a/routes/web.php b/routes/web.php index 56042775..8ed389a2 100644 --- a/routes/web.php +++ b/routes/web.php @@ -315,7 +315,7 @@ Route::middleware(['auth', 'verified', EnsureScopedPortalAdministration::class]) 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'); - Route::post('clusters/{cluster}/competitiveness/{action}', [ClusterCompetitivenessController::class, 'change'])->whereIn('action', ['saved', 'approved', 'withdrawn', 'restored'])->name('clusters.competitiveness.change'); + Route::post('clusters/{cluster}/competitiveness/{action}', [ClusterCompetitivenessController::class, 'change'])->whereIn('action', ['saved', 'approved', 'withdrawn', 'restored', 'submitted', 'review_started', 'returned', 'rejected', 'published', 'archived'])->name('clusters.competitiveness.change'); Route::post('clusters/{cluster}/chain-links', [ClusterValueChainController::class, 'link'])->name('clusters.chain-links.store'); Route::delete('clusters/{cluster}/chain-links/{link}', [ClusterValueChainController::class, 'removeLink'])->name('clusters.chain-links.delete'); Route::post('clusters/{cluster}/gaps', [ClusterValueChainController::class, 'gap'])->name('clusters.gaps.store'); diff --git a/tests/Feature/ClusterEditorialWorkflowTest.php b/tests/Feature/ClusterEditorialWorkflowTest.php new file mode 100644 index 00000000..8134e5da --- /dev/null +++ b/tests/Feature/ClusterEditorialWorkflowTest.php @@ -0,0 +1,189 @@ +assertTrue(IsolatedQaDatabase::allowsSchemaChanges()); + $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.cluster_competitiveness' => true, 'editorial_workflow.independent_approval.kinds' => ['cluster_competitiveness']]); + $this->cluster = EconomicCluster::create(['code' => 'ED-QA-'.bin2hex(random_bytes(6)), 'name' => 'Cluster sintético QA', 'sector' => 'Energia', 'status' => 'planned']); + $this->draft = ['version' => 1, 'clusterId' => $this->cluster->id, 'sector' => 'Energia', 'region' => 'Local sintético', 'forces' => []]; + foreach (ClusterCompetitiveness::FORCES as $force) { + $this->draft['forces'][$force] = ['pressure' => 'medium', 'evidence' => 'Exemplo sintético', 'source' => 'Fonte sintética QA p. 2', 'period' => '2026', + 'rationale' => 'Hipótese sintética', 'limitations' => 'Não são dados reais', 'opportunity' => 'Ideia de demonstração']; + } + } + + private function url(): string + { + return '/management/clusters/'.$this->cluster->id.'/competitiveness'; + } + + private function action(User $actor, string $action, array $extra = [], int $status = 200): array + { + $this->actingAsWithMfa($actor); + $response = $this->postJson($this->url().'/'.$action, ['expected_version' => $this->version, 'reason' => 'Decisão sintética fundamentada em QA', ...$extra])->assertStatus($status); + if ($status !== 200) { + return []; + } + $this->version++; + + return $response->assertJsonPath('version', $this->version)->json(); + } + + private function approved(): array + { + $this->action($this->author, 'saved', ['draft' => $this->draft]); + $this->action($this->author, 'submitted'); + $this->action($this->reviewer, 'review_started'); + + return $this->action($this->reviewer, 'approved'); + } + + public function test_separate_approval_publication_drafts_restore_archive_and_public_projection(): void + { + $dossier = InvestmentDossier::create(['cluster_id' => $this->cluster->id, 'title' => 'Dossier QA', 'source' => 'Fonte sintética', 'content' => ['sector' => 'Energia'], + 'publication_status' => 'published', 'approval_basis' => 'document_owner', 'approval_evidence' => 'Publicação sintética em QA', 'published_at' => now()]); + $approved = $this->approved(); + $service = app(ClusterCompetitiveness::class); + $this->assertNull($service->publicData($this->cluster->id)); + $this->assertSame('approved', $approved['workflow']['state']); + $this->action($this->reviewer, 'published'); + $public = $service->publicData($this->cluster->id); + $this->assertSame($this->draft, $public['draft']); + $this->get('/carteira/'.$dossier->id)->assertInertia(fn (Assert $p) => $p->where('competitiveness.draft.region', 'Local sintético')->missing('competitiveness.workflow')->missing('competitiveness.actor')); + $changed = [...$this->draft, 'region' => 'Edição privada deliberada']; + $draft = $this->action($this->author, 'saved', ['draft' => $changed]); + $this->assertNull($draft['workflow']['approval']); + $this->assertSame($public, $service->publicData($this->cluster->id)); + $this->action($this->author, 'published', [], 409); + $this->action($this->author, 'restored', ['revision' => 1]); + $this->action($this->author, 'archived'); + $this->assertSame($public, $service->publicData($this->cluster->id)); + $this->getJson($this->url())->assertJsonPath('workflow.state', 'archived')->assertJsonPath('draft', $this->draft); + $this->action($this->reviewer, 'withdrawn'); + $this->assertNull($service->publicData($this->cluster->id)); + $this->assertSame($this->version, DB::table('audit_events')->where('source_system', 'cluster-competitiveness')->where('entity_id', $this->cluster->id)->count()); + } + + public function test_independence_contributors_return_reject_and_conflicts(): void + { + $this->action($this->author, 'saved', ['draft' => $this->draft]); + $this->action($this->author, 'submitted'); + $this->action($this->reviewer, 'review_started'); + $this->action($this->author, 'approved', [], 403); + $this->action($this->reviewer, 'returned'); + $this->action($this->reviewer, 'saved', ['draft' => [...$this->draft, 'region' => 'Alteração do revisor']]); + $this->action($this->author, 'saved', ['draft' => $this->draft, 'expected_version' => 0], 409); + $this->action($this->author, 'submitted'); + $this->action($this->reviewer, 'review_started'); + $this->action($this->reviewer, 'approved', [], 403); + $this->action($this->reviewer, 'rejected'); + $this->action($this->author, 'submitted', [], 409); + $this->action($this->author, 'restored', ['revision' => 1]); + $this->getJson($this->url())->assertJsonPath('workflow.state', 'draft')->assertJsonPath('workflow.approval', null); + } + + public function test_changed_cluster_context_or_revoked_approval_requires_new_decision(): void + { + $this->approved(); + $this->cluster->update(['sector' => 'Outro sector sintético']); + $this->getJson($this->url())->assertJsonPath('workflow.fresh', false); + $this->action($this->reviewer, 'published', [], 409); + $this->approved(); + config(['portal.managers' => [$this->author->email]]); + $this->action($this->author, 'published', [], 403); + $this->assertNull(app(ClusterCompetitiveness::class)->publicData($this->cluster->id)); + } + + public function test_legacy_publication_survives_activation_and_disabling_cannot_bypass_workflow(): void + { + config(['editorial_workflow.cluster_competitiveness' => false]); + $this->action($this->author, 'saved', ['draft' => $this->draft]); + $this->action($this->author, 'approved'); + $service = app(ClusterCompetitiveness::class); + $public = $service->publicData($this->cluster->id); + config(['editorial_workflow.cluster_competitiveness' => true]); + $this->action($this->author, 'submitted', [], 409); + $this->action($this->author, 'saved', ['draft' => [...$this->draft, 'region' => 'Nova edição privada']]); + $this->assertSame($public, $service->publicData($this->cluster->id)); + config(['editorial_workflow.cluster_competitiveness' => false]); + $this->action($this->author, 'approved', [], 503); + $this->action($this->author, 'saved', ['draft' => $this->draft], 503); + $this->assertSame($public, $service->publicData($this->cluster->id)); + } + + public function test_permissions_mfa_and_incomplete_evidence_do_not_write(): void + { + $this->getJson($this->url())->assertUnauthorized(); + $this->actingAs(User::factory()->create()); + $this->postJson($this->url().'/published', [])->assertForbidden(); + $this->actingAsWithMfa($this->author); + session()->forget('portal.mfa_at'); + $this->postJson($this->url().'/saved', ['expected_version' => 0, 'draft' => $this->draft])->assertForbidden(); + $draft = $this->draft; + $draft['forces']['buyers']['source'] = ''; + $this->action($this->author, 'saved', ['draft' => $draft]); + $this->action($this->author, 'submitted', [], 422); + $this->action($this->author, 'restored', ['revision' => 999], 404); + $this->getJson($this->url())->assertJsonPath('version', 1)->assertJsonCount(1, 'history'); + } + + public function test_existing_management_entry_and_cluster_list_show_editorial_state_without_new_grants(): void + { + $this->action($this->author, 'saved', ['draft' => $this->draft]); + $this->get('/gestao-conteudos')->assertOk()->assertInertia(fn (Assert $p) => $p->where('access.clusters.index', '/management/clusters')); + $this->get('/management/clusters?q='.$this->cluster->code)->assertOk()->assertInertia(fn (Assert $p) => $p->where('records.data.0.editorial_state', 'draft')->where('records.data.0.status', 'planned')); + $other = User::factory()->create(); + $this->assertArrayNotHasKey('clusters', app(ContentManagement::class)->access($other)); + config(['editorial_workflow.cluster_competitiveness' => false]); + $this->assertArrayNotHasKey('clusters', app(ContentManagement::class)->access($this->author)); + } + + public function test_withdrawal_and_context_changes_do_not_cancel_or_trap_a_private_review(): void + { + $this->approved(); + $this->action($this->reviewer, 'published'); + $this->action($this->author, 'saved', ['draft' => [...$this->draft, 'region' => 'Revisão privada']]); + $this->action($this->author, 'submitted'); + $this->action($this->reviewer, 'review_started'); + $withdrawn = $this->action($this->reviewer, 'withdrawn'); + $this->assertSame('in_review', $withdrawn['workflow']['state']); + $this->assertNull(app(ClusterCompetitiveness::class)->publicData($this->cluster->id)); + $this->cluster->update(['name' => 'Contexto alterado durante revisão']); + $this->action($this->reviewer, 'approved', [], 409); + $returned = $this->action($this->reviewer, 'returned'); + $this->assertSame('returned', $returned['workflow']['state']); + $this->assertSame('Revisão privada', $returned['draft']['region']); + } +} diff --git a/tests/frontend/cluster-editorial.test.mjs b/tests/frontend/cluster-editorial.test.mjs new file mode 100644 index 00000000..a1c5e741 --- /dev/null +++ b/tests/frontend/cluster-editorial.test.mjs @@ -0,0 +1,112 @@ +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); +function load(path, dependencies = {}) { + const source = fs.readFileSync(new URL(path, 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) => dependencies[name] ?? require(name), + module, + module.exports, + ); + return module.exports; +} +const lib = load('../../resources/js/lib/cluster-competitiveness.ts'); +const Page = load('../../resources/js/pages/structures/competitiveness.tsx', { + '@/lib/cluster-competitiveness': lib, + '@/lib/portal-api': { + portalRequest() { + throw new Error('SSR must not send requests'); + }, + PortalApiError: Error, + }, + '../management/shared': { button: '', control: '' }, + '../management/editorial-workflow': { + states: { + draft: 'Rascunho', + approved: 'Aprovado', + published: 'Publicado', + }, + }, +}).default; +const draft = lib.newCompetitivenessDraft('qa-cluster', 'Energia'); +draft.region = 'Local sintético'; +for (const force of lib.forces) { + draft.forces[force] = { + pressure: 'medium', + evidence: 'Dado sintético', + source: 'Fonte QA', + period: '2026', + rationale: 'Hipótese', + limitations: 'Exemplo', + opportunity: 'Ideia', + }; +} +const initial = { + version: 4, + draft, + approved: { draft, version: 4, at: '2026-11-13' }, + published: null, + editorial_enabled: true, + history: [], + workflow: { + state: 'approved', + edition: 1, + hash: 'a'.repeat(64), + fresh: true, + contributors: [1], + independent_required: true, + }, +}; +const render = (state = initial, viewerId = 2) => + renderToStaticMarkup( + React.createElement(Page, { + clusterId: 'qa-cluster', + sector: 'Energia', + initial: state, + viewerId, + }), + ); + +test('approved cluster edition requires explicit publication and retains a prior public version', () => { + const html = render(); + assert.match(html, /Publicar edição aprovada/); + assert.doesNotMatch(html, /Aprovar torna a versão guardada/); + assert.match(html, /Sem análise aprovada para consulta pública/); + const later = render({ + ...initial, + published: { draft, version: 2, at: '2026-11-13' }, + workflow: { ...initial.workflow, state: 'draft' }, + }); + assert.match(later, /Consulta pública: versão 2/); + assert.doesNotMatch(later, /Publicar edição aprovada/); + assert.match(later, /Submeter para revisão/); +}); + +test('changed context disables publication and a disabled workflow remains read only', () => { + const stale = render({ + ...initial, + workflow: { ...initial.workflow, fresh: false }, + }); + assert.match(stale, /O contexto do cluster mudou/); + assert.match( + stale, + /]*disabled=""[^>]*>Publicar edição aprovada<\/button>/, + ); + const disabled = render({ ...initial, editorial_enabled: false }); + assert.match(disabled, /