From 9d3bfa89b273c0bf6e7a57b847d0dd4b53d9af41 Mon Sep 17 00:00:00 2001
From: nala
Date: Thu, 22 Oct 2026 21:27:10 +0200
Subject: [PATCH] feat: prepare audited promotional disclosure interface in QA
---
.../OpportunityManagementController.php | 2 +
.../OpportunityPromotionController.php | 26 +++
.../Controllers/PortalProfileController.php | 4 +
.../Middleware/ProtectProfileOpportunity.php | 4 +-
app/Models/Opportunity.php | 3 +-
app/Services/InvestmentCatalogue.php | 12 ++
app/Services/OpportunityManagement.php | 3 +
app/Services/OpportunityPromotion.php | 167 +++++++++++++++++
.../consolidated-review-matrix-2026-10-03.md | 5 +-
.../unification/traceability-and-manual.md | 20 ++-
resources/js/pages/investment/detail.tsx | 1 +
resources/js/pages/management/content-hub.tsx | 1 +
.../js/pages/management/opportunities.tsx | 2 +
.../js/pages/management/opportunity-draft.tsx | 3 +
.../management/opportunity-promotion.tsx | 77 ++++++++
.../js/pages/profiles/administration.tsx | 27 +--
routes/web.php | 3 +
tests/Feature/OpportunityPromotionTest.php | 170 ++++++++++++++++++
18 files changed, 513 insertions(+), 17 deletions(-)
create mode 100644 app/Http/Controllers/OpportunityPromotionController.php
create mode 100644 app/Services/OpportunityPromotion.php
create mode 100644 resources/js/pages/management/opportunity-promotion.tsx
create mode 100644 tests/Feature/OpportunityPromotionTest.php
diff --git a/app/Http/Controllers/OpportunityManagementController.php b/app/Http/Controllers/OpportunityManagementController.php
index 54a592ae..e6a82817 100644
--- a/app/Http/Controllers/OpportunityManagementController.php
+++ b/app/Http/Controllers/OpportunityManagementController.php
@@ -103,6 +103,7 @@ class OpportunityManagementController extends Controller
$records = $query->orderByDesc('id')->paginate(20)->withQueryString();
$records->getCollection()->each(function (Opportunity $r) use ($access, $service, $request) {
$r->setAttribute('can_edit', $access->canEdit($request->user(), $r));
+ $r->setAttribute('can_promote', app(\App\Services\OpportunityPromotion::class)->allowed($request->user(), $r));
$r->setAttribute('working_status', $access->state($r));
$r->setAttribute('publication', $r->publicationPresentation());
$r->setAttribute('draft_title', $service->values($r)['canonical']['official_title'] ?? $r->title);
@@ -130,6 +131,7 @@ class OpportunityManagementController extends Controller
'profileReauthenticationRequired' => $record && $record->owner_user_id !== null
? app(OpportunityProfileWorkflow::class)->reauthenticationRequired($request->user(), $record) : false,
'publicationState' => $record?->publicationPresentation(),
+ 'promotionHref' => $record && app(\App\Services\OpportunityPromotion::class)->allowed($request->user(), $record) ? '/minhas-oportunidades/'.$record->id.'/divulgacao' : null,
'canonicalResponse' => $record?->owner_user_id !== null ? app(OpportunityCanonicalResponse::class)->read($request->user(), $record) : null,
'canEditDraft' => $record ? $access->canEdit($request->user(), $record) : false,
'workflowParticipants' => $record && $record->owner_user_id !== null ? app(OpportunityProfileWorkflow::class)->participants($request->user(), $record) : ['validators' => [], 'publishers' => []],
diff --git a/app/Http/Controllers/OpportunityPromotionController.php b/app/Http/Controllers/OpportunityPromotionController.php
new file mode 100644
index 00000000..d183be6c
--- /dev/null
+++ b/app/Http/Controllers/OpportunityPromotionController.php
@@ -0,0 +1,26 @@
+editor($request->user(), Opportunity::findOrFail($id)));
+ }
+
+ public function preview(Request $request, int $id, OpportunityPromotion $service): \Illuminate\Http\JsonResponse
+ {
+ return response()->json($service->preview($request->user(), Opportunity::findOrFail($id), $request->all()));
+ }
+
+ public function confirm(Request $request, int $id, OpportunityPromotion $service): \Illuminate\Http\JsonResponse
+ {
+ return response()->json($service->confirm($request->user(), $id, $request->all()));
+ }
+}
diff --git a/app/Http/Controllers/PortalProfileController.php b/app/Http/Controllers/PortalProfileController.php
index 6f069e26..5c402bfe 100644
--- a/app/Http/Controllers/PortalProfileController.php
+++ b/app/Http/Controllers/PortalProfileController.php
@@ -3,6 +3,7 @@
namespace App\Http\Controllers;
use App\Models\Opportunity;
+use App\Models\User;
use App\Services\OpportunityCanonicalContract;
use App\Services\OpportunityContacts;
use App\Services\OpportunityManagement;
@@ -52,6 +53,9 @@ class PortalProfileController extends Controller
'assignments' => DB::table('portal_role_assignments')->get(), 'pair' => DB::table('portal_superuser_pair')->first(),
'operatorOrganizations' => app(PortalInternalDesignation::class)->organizations(),
'internalTypes' => OpportunityCanonicalContract::BASE_CATEGORIES,
+ 'staffAccounts' => User::orderBy('name')->get(['id', 'name', 'email', 'email_verified_at', 'two_factor_confirmed_at'])
+ ->map(fn ($user) => ['id' => $user->id, 'name' => $user->name, 'email' => $user->email,
+ 'verified' => $user->hasVerifiedEmail(), 'mfa' => $user->two_factor_confirmed_at !== null]),
'actorId' => $request->user()->id, 'types' => OpportunityManagement::CATEGORIES,
'organizations' => DB::table('organizations')->whereNull('deleted_at')->get(['id', 'name']),
'securityRequests' => DB::table('portal_security_approvals')->latest()->limit(30)->get(),
diff --git a/app/Http/Middleware/ProtectProfileOpportunity.php b/app/Http/Middleware/ProtectProfileOpportunity.php
index ca097c59..f7ba25ac 100644
--- a/app/Http/Middleware/ProtectProfileOpportunity.php
+++ b/app/Http/Middleware/ProtectProfileOpportunity.php
@@ -29,7 +29,9 @@ class ProtectProfileOpportunity
if ($record && ($record->owner_user_id !== null || str_starts_with($request->path(), 'minhas-oportunidades'))) {
abort_if($record->owner_user_id === null && str_starts_with($request->path(), 'minhas-oportunidades'), 403, 'Registo legado não convertido para a política v1.');
$access = app(PortalProfileAccess::class);
- $access->authorize($request->user(), $request->isMethod('GET') || str_ends_with($request->path(), '/workflow') || str_ends_with($request->path(), '/preview') || $request->is('minhas-oportunidades/*/classification/review') ? 'view' : 'edit', $record);
+ // The promotional controller checks its own scoped disclosure capability,
+ // like workflow decisions; publication does not grant draft editing.
+ $access->authorize($request->user(), $request->isMethod('GET') || str_ends_with($request->path(), '/workflow') || str_ends_with($request->path(), '/preview') || $request->is('minhas-oportunidades/*/classification/review', 'minhas-oportunidades/*/divulgacao/previa', 'minhas-oportunidades/*/divulgacao/confirmar') ? 'view' : 'edit', $record);
abort_if($request->is('management/opportunity-origin/apply'), 403, 'O cadastro privado v1 não usa a classificação editorial dos exemplos legados.');
abort_if($request->isMethod('POST') && str_ends_with($request->path(), '/lifecycle') && ! str_contains($request->path(), '/resources/'), 403, 'Use o workflow com decisões segregadas; o perfil não concede exclusão.');
}
diff --git a/app/Models/Opportunity.php b/app/Models/Opportunity.php
index 6f55cc23..5f18b14e 100644
--- a/app/Models/Opportunity.php
+++ b/app/Models/Opportunity.php
@@ -68,7 +68,8 @@ class Opportunity extends Model
$scope = $public ? 'public' : ($visible && $this->owner_user_id !== null ? 'restricted' : 'unpublished');
return ['scope' => $scope, 'public_catalog' => $public, 'label' => match ($scope) {
- 'public' => 'Publicada no catálogo público',
+ 'public' => data_get($this->workflow_context, 'publication.purpose') === 'promotional_preparation'
+ ? 'Resumo divulgado — Em preparação; não autoriza execução' : 'Publicada no catálogo público',
'restricted' => 'Disponibilizada com acesso restrito — perfis e organização autorizados',
default => 'Sem versão disponibilizada; aprovação interna é uma etapa distinta',
}];
diff --git a/app/Services/InvestmentCatalogue.php b/app/Services/InvestmentCatalogue.php
index 6e351ff1..4b73e684 100644
--- a/app/Services/InvestmentCatalogue.php
+++ b/app/Services/InvestmentCatalogue.php
@@ -308,6 +308,18 @@ class InvestmentCatalogue
/** @return array */
public function context(int $id, bool $management = false): array
{
+ $opportunity = Opportunity::findOrFail($id);
+ if (! $management && data_get($opportunity->workflow_context, 'publication.purpose') === OpportunityPromotion::PURPOSE) {
+ // Promotional disclosure includes only its explicit public coverage.
+ // Private profiles, source evidence and other dossier relations are not auto-published.
+ $public = ['profile' => null, 'facets' => collect(), 'zones' => collect(), 'classification' => [], 'source_evidence' => [],
+ 'primary_territory_path' => app(InvestmentGeography::class)->path($opportunity->primary_territory_id, true),
+ 'opportunity_locations' => app(InvestmentRelations::class)->opportunityLocations($id)];
+ foreach (self::RELATIONS as $relation => $_) $public[$relation] = collect();
+ $public['locations'] = DB::table('opportunity_locations as l')->join('geo_locations as g', 'g.id', '=', 'l.geo_location_id')
+ ->where('l.opportunity_id', $id)->where('g.data_classification', 'public')->whereNull('g.deleted_at')->get(['g.id', 'g.name']);
+ return $public;
+ }
$result = ['profile' => DB::table('opportunity_profiles')->where('opportunity_id', $id)->first(),
'facets' => DB::table('opportunity_facets as f')->join('investment_reference_options as o', 'o.id', '=', 'f.option_id')
->where('f.opportunity_id', $id)->get(['o.id', 'o.group', 'o.label'])];
diff --git a/app/Services/OpportunityManagement.php b/app/Services/OpportunityManagement.php
index e1bc4c1a..8cebfc24 100644
--- a/app/Services/OpportunityManagement.php
+++ b/app/Services/OpportunityManagement.php
@@ -27,6 +27,9 @@ class OpportunityManagement
public function values(Opportunity $record): array
{
$base = $record->only(self::FIELDS);
+ if (data_get($record->workflow_context, 'publication.purpose') === OpportunityPromotion::PURPOSE) {
+ $base = [...$base, ...($record->workflow_context['promotional_private_base'] ?? [])];
+ }
$draft = $record->management_draft ? json_decode($record->management_draft, true, 512, JSON_THROW_ON_ERROR) : [];
return [...$base, ...($this->closed($record) ? $draft : array_intersect_key($draft, array_flip(self::PRIVATE_DRAFT_FIELDS)))];
diff --git a/app/Services/OpportunityPromotion.php b/app/Services/OpportunityPromotion.php
new file mode 100644
index 00000000..623a46d5
--- /dev/null
+++ b/app/Services/OpportunityPromotion.php
@@ -0,0 +1,167 @@
+trashed() || $record->owner_user_id === null || ! $access->canView($actor, $record)
+ || $access->has($actor, 'auditor') || in_array($record->status, ['archived', 'rejected'], true)) return false;
+ // Never overwrite a normal approved publication or its workflow decisions.
+ if ($record->published_at && data_get($record->workflow_context, 'publication.purpose') !== self::PURPOSE) return false;
+ $internal = $access->internalAssignment($actor, $record->opportunity_category);
+ return ($internal && $access->promoterRecordScope($actor, $record) && $access->operational($actor, 'edit', $record->id))
+ || ($access->scoped($actor, 'publisher', $record->organization_id, $record->opportunity_category)
+ && $access->operational($actor, 'publish', $record->id));
+ }
+
+ private function authorize(User $actor, Opportunity $record): void
+ {
+ abort_unless($this->allowed($actor, $record), 403, 'A designação e o âmbito existentes não autorizam esta divulgação.');
+ if (! app(PortalProfileAccess::class)->mfa($actor)) {
+ throw new \Illuminate\Http\Exceptions\HttpResponseException(response()->json(['message' => 'Confirme a entrada normal com MFA recente para divulgar.'], 403)->header('X-Portal-Reauthentication', 'required'));
+ }
+ }
+
+ public function editor(User $actor, Opportunity $record): array
+ {
+ abort_unless($this->allowed($actor, $record), 403);
+ $saved = data_get($record->workflow_context, 'promotional_summary', []);
+ // Only previously public text is recovered automatically. No private contact/document is copied.
+ $existing = data_get($record->workflow_context, 'publication.purpose') === self::PURPOSE;
+ $form = $saved['input'] ?? ['title' => $existing ? $record->title : '', 'summary' => $existing ? $record->summary : '',
+ 'description' => $existing ? $record->description : '', 'source_reference' => '', 'edition' => '', 'source_date' => '',
+ 'limitations' => '', 'territory_ids' => $existing ? DB::table('opportunity_locations')->where('opportunity_id', $record->id)->pluck('geo_location_id')->all() : [],
+ 'sector_id' => $record->sector_id];
+ return ['record' => ['id' => $record->id, 'reference' => $record->reference], 'etag' => app(OpportunityManagement::class)->etag($record),
+ 'initial' => $form, 'publicNow' => $existing && $record->status === 'published',
+ 'reauthenticationRequired' => ! app(PortalProfileAccess::class)->mfa($actor),
+ 'sectors' => \App\Models\Sector::catalog()->orderBy('name')->get(['id', 'name']),
+ 'territories' => app(InvestmentGeography::class)->publicReferences()->orderBy('name')->get(['id', 'name', 'location_type']),
+ 'history' => DB::table('audit_events')->where('source_system', 'tempo-preparation-publication')
+ ->where('after_data->record_id', $record->id)->orderByDesc('occurred_at')->get(['id', 'action', 'actor_user_id', 'occurred_at'])];
+ }
+
+ public function preview(User $actor, Opportunity $record, array $input): array
+ {
+ $this->authorize($actor, $record);
+ $data = Validator::make($input, ['etag' => 'required|string|size:64', 'action' => 'required|in:disclose,withdraw',
+ 'title' => 'required_if:action,disclose|nullable|string|max:255', 'summary' => 'required_if:action,disclose|nullable|string|max:499',
+ 'description' => 'nullable|string|max:6000', 'limitations' => 'nullable|string|max:2500',
+ 'source_reference' => 'nullable|string|max:2000', 'edition' => 'nullable|string|max:150', 'source_date' => 'nullable|date_format:Y-m-d',
+ 'sector_id' => ['nullable', 'integer', Rule::in(\App\Models\Sector::catalog()->pluck('id')->all())],
+ 'territory_ids' => 'sometimes|array|max:30', 'territory_ids.*' => 'required|uuid|distinct',
+ ])->validate();
+ abort_unless(hash_equals(app(OpportunityManagement::class)->etag($record), $data['etag']), 409, 'A ficha mudou. Reabra antes de preparar outra prévia.');
+ if ($data['action'] === 'withdraw') abort_unless(data_get($record->workflow_context, 'publication.purpose') === self::PURPOSE, 409);
+ $paths = [];
+ foreach ($data['territory_ids'] ?? [] as $id) {
+ $path = app(InvestmentGeography::class)->path($id, true);
+ abort_unless(count($path) > 0 && collect($path)->contains('id', $id), 422, 'Seleccione apenas territórios públicos existentes.');
+ $paths[] = $path;
+ }
+ $text = fn ($key) => trim(strip_tags($data[$key] ?? ''));
+ $body = implode("\n\n", array_filter([$text('description'), self::NOTICE,
+ 'Limitações e lacunas: '.($text('limitations') ?: 'Informação ainda por completar.'),
+ 'Fonte: '.($text('source_reference') ?: 'Não indicada.'), 'Edição: '.($text('edition') ?: 'Não indicada.'),
+ 'Data-base: '.($data['source_date'] ?? 'Não indicada.')]));
+ $projection = ['title' => $text('title'), 'summary' => $text('summary'), 'description' => $body,
+ 'sector_id' => $data['sector_id'] ?? null, 'maturity_label' => 'Em preparação', 'notice' => self::NOTICE,
+ 'territories' => array_map(fn ($p) => ['id' => end($p)->id, 'label' => implode(' / ', array_column($p, 'name'))], $paths)];
+ if ($data['action'] === 'disclose') abort_unless($projection['title'] !== '' && $projection['summary'] !== '', 422, 'Indique título e resumo em texto simples.');
+ if ($data['action'] === 'withdraw') $projection = [...$projection, ...$record->only(['title', 'summary', 'description', 'sector_id', 'maturity_label'])];
+ $payload = ['actor_id' => $actor->id, 'record_id' => $record->id, 'etag' => $data['etag'], 'action' => $data['action'],
+ 'input' => $data, 'projection' => $projection, 'expires' => now()->addMinutes(15)->timestamp];
+ return ['action' => $data['action'], 'projection' => $projection, 'token' => Crypt::encryptString(json_encode($payload, JSON_THROW_ON_ERROR)),
+ 'current' => $record->only(['title', 'summary', 'description', 'status']), 'writes' => false];
+ }
+
+ public function confirm(User $actor, int $id, array $input): array
+ {
+ $data = Validator::make($input, ['token' => 'required|string|max:300000', 'operation_key' => 'required|uuid',
+ 'confirm' => 'required|accepted', 'reason' => 'required|string|min:10|max:1000'])->validate();
+ try { $preview = json_decode(Crypt::decryptString($data['token']), true, flags: JSON_THROW_ON_ERROR); }
+ catch (\Throwable) { abort(422, 'Prévia inválida. Prepare outra prévia.'); }
+ abort_unless(($preview['actor_id'] ?? null) === $actor->id && ($preview['record_id'] ?? null) === $id, 403);
+ return DB::transaction(function () use ($actor, $id, $data, $preview) {
+ DB::select('SELECT pg_advisory_xact_lock(hashtext(?))', ['promotional-operation:'.$data['operation_key']]);
+ $record = Opportunity::whereKey($id)->lockForUpdate()->firstOrFail();
+ $this->authorize($actor, $record);
+ $digest = hash('sha256', $data['token'].'|'.$data['reason']);
+ $replay = DB::table('audit_events')->where('source_system', 'tempo-preparation-publication')
+ ->where('after_data->operation_key', $data['operation_key'])->first();
+ if ($replay) {
+ $prior = json_decode($replay->after_data, true);
+ abort_unless($prior['record_id'] === $id && (int) $replay->actor_user_id === $actor->id && hash_equals($prior['request_hash'], $digest), 409, 'Chave usada noutra operação.');
+ return ['id' => $id, 'replayed' => true, 'message' => 'Operação já registada; nenhuma alteração repetida.'];
+ }
+ abort_unless(($preview['expires'] ?? 0) >= now()->timestamp, 409, 'A prévia expirou. Prepare outra prévia.');
+ abort_unless(hash_equals(app(OpportunityManagement::class)->etag($record), $preview['etag']), 409, 'Alteração concorrente: a ficha foi preservada. Reabra e reveja a nova versão.');
+ $before = $record->getRawOriginal();
+ $oldLocations = DB::table('opportunity_locations')->where('opportunity_id', $id)->pluck('geo_location_id')->all();
+ $context = $record->workflow_context ?? [];
+ if ($preview['action'] === 'withdraw') {
+ $record->status = 'suspended';
+ $record->published_at = null;
+ $context['publication']['visibility'] = 'withdrawn';
+ $action = 'promotional_summary_withdrawn';
+ } else {
+ $p = $preview['projection'];
+ if ($p['sector_id'] !== null) abort_unless(\App\Models\Sector::catalog()->whereKey($p['sector_id'])->exists(), 409, 'O sector deixou de estar disponível no catálogo. Prepare outra prévia.');
+ $paths = [];
+ foreach ($preview['input']['territory_ids'] ?? [] as $territory) {
+ $path = app(InvestmentGeography::class)->path($territory, true);
+ abort_unless(count($path) && collect($path)->contains('id', $territory), 409, 'Um território deixou de estar disponível. Prepare outra prévia.');
+ $paths[] = $path;
+ }
+ $currentTerritories = array_map(fn ($path) => ['id' => end($path)->id, 'label' => implode(' / ', array_column($path, 'name'))], $paths);
+ abort_unless($currentTerritories === $p['territories'], 409, 'O enquadramento territorial mudou. Prepare outra prévia.');
+ // Preserve the original private draft bytes; retain its former base
+ // columns in the existing hidden context before projecting public text.
+ $context['promotional_private_base'] ??= $record->only(OpportunityManagement::FIELDS);
+ $context['working_state'] ??= app(PortalProfileAccess::class)->state($record);
+ $context['promotional_summary'] = ['input' => $preview['input'], 'projection' => $p];
+ $context['publication'] = ['visibility' => 'public', 'purpose' => self::PURPOSE, 'actor_id' => $actor->id,
+ 'at' => now()->toIso8601String(), 'execution_approved' => false, 'private_draft_preserved' => true];
+ $action = data_get($before, 'status') === 'published' ? 'promotional_summary_updated' : 'promotional_summary_published';
+ foreach (['title', 'summary', 'description', 'sector_id', 'maturity_label'] as $field) $record->{$field} = $p[$field];
+ foreach (['promoter_name', 'promoter_type', 'financing_modality', 'expected_impact', 'maturity_level', 'estimated_investment_amount', 'estimated_investment_currency'] as $field) $record->{$field} = null;
+ $main = $paths[0] ?? [];
+ $record->primary_territory_id = $main ? end($main)->id : null;
+ $record->province = collect($main)->firstWhere('location_type', 'province')?->name;
+ $record->district = collect($main)->firstWhere('location_type', 'district')?->name;
+ $record->geography_scope = count($paths) > 1 ? 'multiple' : ($main ? 'specific' : 'unspecified');
+ DB::table('opportunity_locations')->where('opportunity_id', $id)->delete();
+ foreach ($preview['input']['territory_ids'] ?? [] as $territory) DB::table('opportunity_locations')->insert(['opportunity_id' => $id, 'geo_location_id' => $territory]);
+ $record->status = 'published';
+ $record->is_featured = false;
+ $record->last_validated_at = null;
+ $record->published_at = now();
+ }
+ $record->workflow_context = $context;
+ $record->management_version++;
+ $record->save();
+ DB::table('audit_events')->insert(['source_system' => 'tempo-preparation-publication', 'entity_type' => 'opportunity',
+ 'actor_user_id' => $actor->id, 'action' => $action, 'data_classification' => 'restricted', 'correlation_id' => (string) Str::uuid(),
+ 'before_data' => json_encode(['record_id' => $id, 'record' => $before, 'territories' => $oldLocations], JSON_THROW_ON_ERROR),
+ 'after_data' => json_encode(['record_id' => $id, 'operation_key' => $data['operation_key'], 'request_hash' => $digest,
+ 'reason' => $data['reason'], 'projection' => $preview['projection'], 'execution_approved' => false,
+ 'record' => $record->getRawOriginal()], JSON_THROW_ON_ERROR)]);
+ return ['id' => $id, 'replayed' => false, 'message' => $preview['action'] === 'withdraw' ? 'Resumo retirado; cadastro e histórico preservados.' : 'Resumo divulgado como Em preparação. Não autoriza execução do investimento.'];
+ });
+ }
+}
diff --git a/docs/investmoz/consolidated-review-matrix-2026-10-03.md b/docs/investmoz/consolidated-review-matrix-2026-10-03.md
index c49f4e33..c86e9f6d 100644
--- a/docs/investmoz/consolidated-review-matrix-2026-10-03.md
+++ b/docs/investmoz/consolidated-review-matrix-2026-10-03.md
@@ -319,4 +319,7 @@ Situação comum observada: `publication_status=draft`, `validation_status=valid
### Referência da entrada única de gestão — QA, 22/10/2026
Nos requisitos existentes `MASTER-9f6dd1d9-0001`, `MASTER-9f6dd1d9-0003` e `MASTER-02f44baf-0002`, acrescentar como evidência delimitada a secção «Entrada única de gestão — preparação em QA, 22/10/2026» do manual de rastreabilidade e `tests/Feature/ContentManagementEntryTest.php`: quatro testes/99 asserções, ClamAV real e reversão transaccional em QA. Origem do objectivo e das regras operacionais: decisão expressa da Tempo; navegação, filtros e recuperação do upload são escolhas de implementação. Reutilizados biblioteca, armazenamento, perfis e histórico. Não altera os critérios de aceitação nem fecha os requisitos integrais: integração/contas reais e categorias não demonstradas conservam o seu estado. Geometrias permanecem separadas.
-Conferência de integração de 22/10/2026 (requisitos documentais MASTER-9f6dd1d9-0001/0003 e reutilização MASTER-02f44baf-0002): 8286d750 servido, acesso real de Nala restituído removendo dependência indevida da reserva técnica do host; lista nominal, MFA e âmbito preservados. Ver secção «Entrada única — integração e conferência real» do manual. Não encerrar o fluxo integral das oportunidades: ausência de Publicador activo e publicação promocional ainda por executor privado são saldos explícitos; não condicionam os acessos já aprovados. Não foram criadas contas, matrizes ou papéis adicionais.
\ No newline at end of file
+Conferência de integração de 22/10/2026 (requisitos documentais MASTER-9f6dd1d9-0001/0003 e reutilização MASTER-02f44baf-0002): 8286d750 servido, acesso real de Nala restituído removendo dependência indevida da reserva técnica do host; lista nominal, MFA e âmbito preservados. Ver secção «Entrada única — integração e conferência real» do manual. Não encerrar o fluxo integral das oportunidades: ausência de Publicador activo e publicação promocional ainda por executor privado são saldos explícitos; não condicionam os acessos já aprovados. Não foram criadas contas, matrizes ou papéis adicionais.
+### Divulgação promocional corrente — QA, 22/10/2026
+
+Evidência complementar aos requisitos existentes MASTER-9f6dd1d9-0001/0003 (gestão documental), MASTER-02f44baf-0002 (reutilização) e MASTER-6e51cfcd-0001 (oportunidades): secção «Divulgação promocional pela equipa — implementação QA» do manual e `OpportunityPromotionTest`. Por decisão expressa da Tempo, a divulgação de resumos Em preparação tem prévia, confirmação, actualização, retirada e auditoria na interface, independente da aprovação de execução e do workflow de oportunidade completa. Reutilizados os perfis operacionais e o âmbito existentes; zero novos papéis/aprovadores. Removida a dependência do executor privado para esta operação corrente em QA. Origem dos controlos de versão/token: escolha técnica de integridade. Não alterar os critérios originais nem dar por concluídas a instalação no activo, a disponibilidade das contas ou as variantes não demonstradas. Mario conserva a designação aprovada e pode ser seleccionado por nome quando a conta/requisitos estiverem disponíveis. Geometrias excluídas.
\ No newline at end of file
diff --git a/docs/investmoz/unification/traceability-and-manual.md b/docs/investmoz/unification/traceability-and-manual.md
index b2a75ccf..499aaefc 100644
--- a/docs/investmoz/unification/traceability-and-manual.md
+++ b/docs/investmoz/unification/traceability-and-manual.md
@@ -9,6 +9,7 @@
| Oportunidades | **Nova oportunidade** → escolher promotor real e modalidade → preencher → **Guardar rascunho**. Recuperar em **Gerir oportunidades e histórico**; abrir a ficha, completar **Imagens, documentos e localizações** e **Submeter para validação**. O Validador usa a fila de revisão e as acções **Iniciar validação**, **Guardar revisão especializada**, pedir correcções ou **Aprovar versão**, conforme a tarefa atribuída. O Publicador usa a fila de publicação e **Publicar no catálogo público** na versão aprovada com visibilidade pública. A equipa conclui as funções que já lhe foram atribuídas; a criação não concede revisão nem publicação. |
| Documentos | **Novo documento** → escolher ficheiro e origem conhecida → **Guardar em quarentena** → conferir a análise indicada no ecrã → completar **Registar documento na biblioteca** → **Guardar como rascunho**. Recuperar pela pesquisa em **Gerir conteúdos, versões e histórico**; usar **Editar metadados** para rever; guardar as alterações e usar **Publicar**. Se o scanner comunicar falha, usar **Tentar novamente**; avaria persistente é manutenção técnica, não uma nova aprovação documental. |
| Legislação | **Nova legislação** → mesmo percurso documental, com tipo Legislação pré-seleccionado → registar entidade, número, edição, fonte, datas e situação conhecida → guardar → pesquisar/reabrir → rever metadados/associações → **Publicar**. Fonte oficial identificada mantém a aceitação da Tempo; não exige segunda certificação. A publicação permite consulta e não activa regras fiscais. |
+| Divulgação promocional — candidata QA | **Gerir oportunidades e histórico → Divulgação promocional** (também na ficha) → preencher apenas título/resumo/texto, fonte/edição/data-base e abrangência a divulgar → **Preparar prévia de divulgação** → rever o conteúdo público → motivo e confirmação → **Confirmar divulgação do resumo**. Para actualizar: reabrir → alterar o resumo → **Rever actualização do resumo** → confirmar. Para retirar: **Preparar retirada do resumo → Confirmar retirada do resumo**. O estado público é **Em preparação**; não conclui o workflow nem autoriza execução. Não inclui contactos ou anexos privados. Esta acção está preparada em QA e não foi instalada na homologação nesta passagem. |
Para retirar um documento, abrir o registo e usar **Retirar da publicação**; original e histórico ficam preservados. Para oportunidades, usar as acções de suspensão/arquivo apresentadas à função responsável. Não se apagam registos para os retirar do público.
@@ -2267,4 +2268,21 @@ Na mesma sessão real, após instalação, a entrada passou a mostrar Novo docum
**Dependências que permanecem explícitas:** (1) o fluxo normal de oportunidades tem interface, mas a lista actual não tem Publicador activo; falta a disponibilidade da conta/função já designada, não um comando do Codex. Nala não recebe outro papel por inferência. (2) A divulgação promocional de resumos em preparação, usada no lote Inhambane, foi operação por executor privado; ainda não existe acção genérica equivalente na interface. A candidata não encerra essa autonomia específica, nem as variantes de submissão que o editor já identifica como não implementadas. (3) manutenção de indisponibilidade persistente do scanner/worker continua técnica; as operações documentais normais não exigem comandos. Geometrias administrativas permanecem em saldo separado.
-Link único e guia curto encontram-se no início deste manual: https://teste-investidor.tempo.co.mz/gestao-conteudos. Recibo e imagem da conferência: outputs/content-management-delivery-20261022/. Registo documental mantido na branch QA, sem novo push main/desencadeamento de instalação apenas para o recibo.
\ No newline at end of file
+Link único e guia curto encontram-se no início deste manual: https://teste-investidor.tempo.co.mz/gestao-conteudos. Recibo e imagem da conferência: outputs/content-management-delivery-20261022/. Registo documental mantido na branch QA, sem novo push main/desencadeamento de instalação apenas para o recibo.
+## Divulgação promocional pela equipa — implementação QA, 22/10/2026
+
+Origem: instrução expressa da Tempo nesta conversa para eliminar a dependência do executor privado na divulgação corrente de resumos em preparação. A autorização promocional é distinta de aprovação interna/publicação de uma oportunidade completa e de autorização externa de execução. Não se criaram papéis nem aprovadores. Reutiliza-se o Promotor **interno**, no âmbito operacional e cadastro que lhe foram atribuídos, e o Publicador no seu âmbito existente. O Promotor externo não ganha publicação por esta alteração. Auditor, âmbito alheio, MFA ausente/expirado e publicação normal já disponibilizada continuam a ser recusados pelas regras existentes. Esta passagem limita-se a QA; nenhuma atribuição ou conteúdo activo foi alterado.
+
+A lista e a ficha da oportunidade oferecem **Divulgação promocional**. O editor recupera o último resumo deliberadamente divulgado; não copia automaticamente texto, contactos ou documentos privados. A prévia apresenta título, resumo, descrição com aviso, fonte/edição/data-base e abrangência escolhida. Ausências são explicitadas, não preenchidas por presunção; a incompletude do projecto não exige a submissão completa para divulgar o resumo. Confirmar vincula o utilizador, operação, conteúdo exacto e versão da ficha à prévia cifrada, válida 15 minutos. Prazo, chave idempotente, serialização e comparação de versão são escolhas técnicas de integridade, não novas aprovações organizacionais. Cancelar não escreve. Alteração concorrente ou referência territorial modificada exige outra prévia, sem substituir o cadastro.
+
+Os mesmos registos de oportunidade, contexto privado e auditoria são reutilizados. O rascunho original mantém os bytes; as colunas privadas anteriores à projecção pública ficam preservadas no contexto oculto e no histórico restrito. A projecção não transporta anexos, contactos, montantes privados, validação documental ou perfis privados. A consulta pública promocional recupera apenas a abrangência explicitamente divulgada; não publica automaticamente evidências ou relações do dossier. Retirar deixa o resumo indisponível em público, preservando a ficha, documentos, relações privadas e histórico. Não equivale a arquivar ou rejeitar o investimento.
+
+Ensaios novos em base isolada `simulator_fiscal_delta_20261021_test`, loopback 55441, com guarda e transacções revertidas: ciclo de prévia/divulgação/actualização/retirada/nova divulgação, repetição sem duplicar auditoria, preservação do original privado e metadados/vínculo de documento em quarentena, projecção pública sem conteúdo privado, ausência de concessão de papéis/aprovação, recusa de sessão/âmbito não autorizados, prévia adulterada/expirada/desactualizada e recuperação. A integração com o middleware foi ajustada para delegar a autorização destas duas operações ao serviço promocional; um Publicador não recebe por isso edição do rascunho.
+
+No navegador, conta sintética de Promotor interno autenticada normalmente com MFA; ficha sintética 255, `SINTETICO-QA-PROMO-20261022`: prévia revista → confirmação → ficha pública com Em preparação, fonte, lacunas e abrangência Cabo Delgado → recarregar e recuperar → actualizar o resumo → rever e confirmar → retirar com prévia → consulta pública 404 e histórico com três operações. Não foram usados comandos para executar essas operações. A fixture preparou apenas o cadastro/identidade de teste e ficou retirada do público QA; não é dado de entrega. A primeira tentativa MFA da fixture tinha formato de cifra incompatível com Fortify e foi corrigida apenas na fixture; não se alterou a autenticação nem se utilizou essa tentativa como prova. Provas em `outputs/promotional-interface-20261022/01-previa.png`, `02-historico-retirada.png`, `tests-final.log`, `types.log` e `build-final.log`.
+
+Para Mario, mantém-se a designação de Publicador já aprovada, sem pedir outra decisão da Tempo. A gestão de perfis passou a permitir seleccionar a conta existente por nome/email e mostrar a verificação de email/MFA, em vez de exigir descoberta de IDs por comandos. Quando a conta estiver disponível e cumprir os requisitos existentes, a dupla autorizada aplica a atribuição interna no mesmo ecrã de perfis e no âmbito aprovado, com auditoria. Não foram criadas contas nem aplicados papéis no activo. A indisponibilidade de outras contas não bloqueia as operações já autorizadas.
+
+Fecho deste recorte: as operações correntes incluídas têm percurso de interface em QA; a divulgação promocional já não exige o executor privado. Provas documentais, criação/gravação normal de oportunidades e revisão/publicação normal já aceites foram reutilizadas. Não se declara nova demonstração integral desses fluxos nem instalação no activo. Permanecem separados: disponibilização da candidata pelo processo Git após revisão, atribuições reais quando as contas estiverem disponíveis, variantes de oportunidades ainda não implementadas e geometrias administrativas. Publicação documental continua a disponibilizar consulta, sem activar cálculos fiscais.
+
+Validação final deste delta QA: cinco testes/81 asserções passaram em 116,299 s, com transacções revertidas; TypeScript e build QA passaram. As provas visuais cobrem as três operações promocionais novas; as provas documentais e do workflow normal continuam reutilizadas, sem repetição. Nenhuma fixture, segredo, build QA ou atribuição sintética integra os ficheiros versionados.
diff --git a/resources/js/pages/investment/detail.tsx b/resources/js/pages/investment/detail.tsx
index 95468ad5..55e8b826 100644
--- a/resources/js/pages/investment/detail.tsx
+++ b/resources/js/pages/investment/detail.tsx
@@ -32,6 +32,7 @@ export default function Detail({
const [sharing, setSharing] = useState('');
return (
+ {r.maturity_label === 'Em preparação' &&
Em preparação — divulgação promocional. A divulgação não autoriza a execução do investimento.
}
Sector económico e modalidade são escolhas distintas. Pode guardar preparação incompleta; o editor identifica os campos e variantes ainda sem suporte de submissão.
{access.opportunities.create && Nova oportunidade}
Gerir oportunidades e histórico
+
Para divulgar um resumo ainda em preparação, abra a ficha e escolha Divulgação promocional: prepare o texto público, reveja a prévia e confirme divulgar, actualizar ou retirar. Esta operação não conclui a validação do investimento.
As etapas e a independência já definidas para oportunidades mantêm-se. Esta entrada não atribui autorização para rever ou publicar.
{!access.opportunities.profileWorkflow &&
O seu acesso actual abre o cadastro legado de rascunhos. A submissão, revisão e publicação de novas oportunidades utilizam os perfis operacionais já existentes; a administração de conteúdos não substitui essa designação. Esta entrada não apresenta o rascunho legado como percurso completo de publicação.
Prepare o resumo público e reveja a prévia antes de confirmar. A divulgação fica identificada como Em preparação e não autoriza a execução do investimento. Documentos e contactos do cadastro continuam privados.
+
{publicNow ? 'Resumo actualmente divulgado.' : 'Resumo não divulgado.'} {message}
+ {needsMfa &&
Entre novamente com a sua conta e MFA para confirmar esta operação. O cadastro guardado permanece disponível; a prévia ainda não confirmada terá de ser preparada novamente.
}
+ {Object.entries(errors).map(([key,value]) =>
{key}: {value}
)}
+
+ {preview &&
+
{preview.action === 'withdraw' ? 'Rever retirada do catálogo' : 'Prévia do conteúdo público'}
Esta prévia ainda não alterou o portal público. {preview.action === 'withdraw' ? 'A confirmação retira o resumo da consulta pública e preserva o cadastro.' : 'Só este texto e o enquadramento apresentado serão divulgados; os anexos e contactos não são incluídos.'}
A proprietária define as funções da sua equipa. Esta atribuição não exige comprovativos de Promotor externo nem concede gestão empresarial. Cada oportunidade conserva o promotor real e a autorização de divulgação.