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.

} diff --git a/resources/js/pages/management/opportunities.tsx b/resources/js/pages/management/opportunities.tsx index faa2bbbb..958cd32b 100644 --- a/resources/js/pages/management/opportunities.tsx +++ b/resources/js/pages/management/opportunities.tsx @@ -17,6 +17,7 @@ type Entry = { updated_at: string; deleted_at: string | null; can_edit?: boolean; + can_promote?: boolean; working_status?: string; draft_title?: string; publication?: { scope: string; label: string; public_catalog: boolean }; @@ -232,6 +233,7 @@ export default function Opportunities({

Atualizado: {r.updated_at}

Ver + {r.can_promote && Divulgação promocional} {r.can_edit !== false && ( {r.deleted_at || r.status === 'archived' diff --git a/resources/js/pages/management/opportunity-draft.tsx b/resources/js/pages/management/opportunity-draft.tsx index 299fc395..5e25d720 100644 --- a/resources/js/pages/management/opportunity-draft.tsx +++ b/resources/js/pages/management/opportunity-draft.tsx @@ -75,9 +75,11 @@ export default function OpportunityDraft({ workflowParticipants, publicationPreview, publicationState, + promotionHref, auth, canEditDraft = false, } = usePage<{ + promotionHref?: string | null; basePath?: string; profileMode?: boolean; profileOrganization?: string | null; @@ -246,6 +248,7 @@ export default function OpportunityDraft({ {profileMode ? 'Minhas oportunidades' : 'Voltar à gestão'} + {promotionHref && Divulgação promocional — Em preparação} {record && ( ({...initial, source_date: initial.source_date ?? ''}); + const [preview, setPreview] = useState(null); + const [operationKey, setOperationKey] = useState(createOperationKey); + const [reason, setReason] = useState(''); + const [confirmed, setConfirmed] = useState(false); + const [busy, setBusy] = useState(false); + const [message, setMessage] = useState(''); + const [errors, setErrors] = useState>({}); + const [needsMfa, setNeedsMfa] = useState(reauthenticationRequired); + function change(key: K, value: Draft[K]) { setDraft(current => ({...current, [key]:value})); setPreview(null); setConfirmed(false); } + function report(error: unknown) {setMessage(error instanceof Error ? error.message : 'Não foi possível concluir. A ficha anterior foi preservada.'); setErrors(error instanceof ProfileValidationError ? error.fields : {}); if(error instanceof ProfileValidationError && error.reauthenticate)setNeedsMfa(true);} + function renewAuthentication() { router.post('/logout', {}, {onSuccess:()=>router.visit(`/minhas-oportunidades/${record.id}/divulgacao`)}); } + async function prepare(action: 'disclose' | 'withdraw') { + setBusy(true); setMessage(''); setErrors({}); setPreview(null); setConfirmed(false); + try { const result = await profileJson(`/minhas-oportunidades/${record.id}/divulgacao/previa`, {...draft, etag, action}); setPreview(result); setOperationKey(createOperationKey()); } + catch (error) { report(error); } finally { setBusy(false); } + } + async function publish() { + if (!preview || !confirmed) return; + setBusy(true); setErrors({}); + try {const result = await profileJson(`/minhas-oportunidades/${record.id}/divulgacao/confirmar`, {token:preview.token, operation_key:operationKey, reason, confirm:confirmed}); + setMessage(result.message); setPreview(null); setConfirmed(false); router.reload(); + } catch (error) { report(error); } finally {setBusy(false);} + } + const labels: Record = {promotional_summary_published:'Resumo divulgado', promotional_summary_updated:'Resumo actualizado', promotional_summary_withdrawn:'Resumo retirado'}; + return + Voltar à oportunidade +

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}

)} +
{e.preventDefault(); void prepare('disclose');}}> + change('title',e.target.value)}/> +