Compare commits

...
Author SHA1 Message Date
nala 9d3bfa89b2 feat: prepare audited promotional disclosure interface in QA 2026-10-22 21:27:10 +02:00
18 changed files with 513 additions and 17 deletions
@@ -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' => []],
@@ -0,0 +1,26 @@
<?php
namespace App\Http\Controllers;
use App\Models\Opportunity;
use App\Services\OpportunityPromotion;
use Illuminate\Http\Request;
use Inertia\Inertia;
final class OpportunityPromotionController extends Controller
{
public function show(Request $request, int $id, OpportunityPromotion $service): \Inertia\Response
{
return Inertia::render('management/opportunity-promotion', $service->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()));
}
}
@@ -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(),
@@ -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.');
}
+2 -1
View File
@@ -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',
}];
+12
View File
@@ -308,6 +308,18 @@ class InvestmentCatalogue
/** @return array<string,mixed> */
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'])];
+3
View File
@@ -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)))];
+167
View File
@@ -0,0 +1,167 @@
<?php
namespace App\Services;
use App\Models\Opportunity;
use App\Models\User;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
/** Tempo's promotional disclosure, independent of investment execution approval. */
final class OpportunityPromotion
{
public const PURPOSE = 'promotional_preparation';
public const NOTICE = 'Em preparação. A divulgação não confirma financiamento, disponibilidade de terreno, licenças ou autorização de execução do investimento.';
public function allowed(User $actor, Opportunity $record): bool
{
$access = app(PortalProfileAccess::class);
if ($record->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.'];
});
}
}
@@ -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.
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.
@@ -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.
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.
+1
View File
@@ -32,6 +32,7 @@ export default function Detail({
const [sharing, setSharing] = useState('');
return (
<Frame title={r.title}>
{r.maturity_label === 'Em preparação' && <p className="rounded border border-amber-700 bg-amber-50 p-4"><strong>Em preparação — divulgação promocional.</strong> A divulgação não autoriza a execução do investimento.</p>}
<Link
className={button}
href={'/investimentos/iniciar?source=opportunity:' + r.id}
@@ -16,6 +16,7 @@ export default function ContentHub({access, counts}: {access: Access; counts: Re
<p>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.</p>
{access.opportunities.create && <Link className={button} href={access.opportunities.create}>Nova oportunidade</Link>}
<Link className="block underline" href={access.opportunities.index}>Gerir oportunidades e histórico</Link>
<p>Para divulgar um resumo ainda em preparação, abra a ficha e escolha <strong>Divulgação promocional</strong>: 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.</p>
<ul>{Object.entries(counts.opportunities ?? {}).map(([state, total]) => <li key={state}><Link className="underline" href={`${access.opportunities!.index}?status=${state}`}>{states[state] ?? state}: {total}</Link></li>)}</ul>
<p className="text-sm">As etapas e a independência já definidas para oportunidades mantêm-se. Esta entrada não atribui autorização para rever ou publicar.</p>
{!access.opportunities.profileWorkflow && <p>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.</p>}
@@ -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({
<p>Atualizado: {r.updated_at}</p>
<div className="flex flex-wrap gap-4">
<Link href={`${basePath}/${r.id}/view`}>Ver</Link>
{r.can_promote && <Link href={`/minhas-oportunidades/${r.id}/divulgacao`}>Divulgação promocional</Link>}
{r.can_edit !== false && (
<Link href={`${basePath}/${r.id}/edit`}>
{r.deleted_at || r.status === 'archived'
@@ -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({
<Link href={basePath}>
{profileMode ? 'Minhas oportunidades' : 'Voltar à gestão'}
</Link>
{promotionHref && <Link className="ml-3 underline" href={promotionHref}>Divulgação promocional — Em preparação</Link>}
{record && (
<Link
href={`/investimentos/iniciar?source=opportunity:${record.id}`}
@@ -0,0 +1,77 @@
import { Link, router } from '@inertiajs/react';
import { useState } from 'react';
import { createOperationKey } from '@/lib/operation-key';
import { profileJson, ProfileValidationError } from '@/lib/profile-json';
import { Management, Field, control, button } from './shared';
type Draft = {title: string; summary: string; description: string; limitations: string; source_reference: string; edition: string; source_date: string; sector_id: number | null; territory_ids: string[]};
type Projection = {title: string; summary: string; description: string; sector_id: number | null; maturity_label: string; territories: {id: string; label: string}[]};
type Preview = {token: string; action: 'disclose' | 'withdraw'; projection: Projection; current: {title: string; summary: string; description: string; status: string}};
export default function OpportunityPromotion({record, initial, etag, publicNow, reauthenticationRequired, sectors, territories, history}: {
record: {id: number; reference: string}; initial: Draft; etag: string; publicNow: boolean; reauthenticationRequired: boolean;
sectors: {id: number; name: string}[]; territories: {id: string; name: string; location_type: string}[];
history: {id: string; action: string; actor_user_id: number; occurred_at: string}[];
}) {
const [draft, setDraft] = useState<Draft>({...initial, source_date: initial.source_date ?? ''});
const [preview, setPreview] = useState<Preview | null>(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<Record<string,string>>({});
const [needsMfa, setNeedsMfa] = useState(reauthenticationRequired);
function change<K extends keyof Draft>(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<string,string> = {promotional_summary_published:'Resumo divulgado', promotional_summary_updated:'Resumo actualizado', promotional_summary_withdrawn:'Resumo retirado'};
return <Management title={`Divulgação promocional — ${record.reference}`}>
<Link className="underline" href={`/minhas-oportunidades/${record.id}/view`}>Voltar à oportunidade</Link>
<p>Prepare o resumo público e reveja a prévia antes de confirmar. A divulgação fica identificada como <strong>Em preparação</strong> e não autoriza a execução do investimento. Documentos e contactos do cadastro continuam privados.</p>
<p role="status">{publicNow ? 'Resumo actualmente divulgado.' : 'Resumo não divulgado.'} {message}</p>
{needsMfa && <p role="alert">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. <button className="underline" onClick={renewAuthentication}>Renovar entrada com MFA</button></p>}
{Object.entries(errors).map(([key,value]) => <p role="alert" key={key}>{key}: {value}</p>)}
<form className="space-y-4 rounded border p-5" onSubmit={e=>{e.preventDefault(); void prepare('disclose');}}>
<Field label="Título público"><input required className={control} maxLength={255} value={draft.title} onChange={e=>change('title',e.target.value)}/></Field>
<Field label="Resumo público"><textarea required className={control} maxLength={499} value={draft.summary} onChange={e=>change('summary',e.target.value)}/></Field>
<Field label="Descrição pública"><textarea className={control} maxLength={6000} value={draft.description} onChange={e=>change('description',e.target.value)}/></Field>
<Field label="Limitações e lacunas conhecidas"><textarea className={control} value={draft.limitations} onChange={e=>change('limitations',e.target.value)}/></Field>
<Field label="Fonte do resumo"><textarea className={control} value={draft.source_reference} onChange={e=>change('source_reference',e.target.value)}/></Field>
<Field label="Edição"><input className={control} value={draft.edition} onChange={e=>change('edition',e.target.value)}/></Field>
<Field label="Data-base"><input className={control} type="date" value={draft.source_date} onChange={e=>change('source_date',e.target.value)}/></Field>
<Field label="Sector divulgado"><select className={control} value={draft.sector_id ?? ''} onChange={e=>change('sector_id',e.target.value ? Number(e.target.value):null)}><option value="">Por indicar</option>{sectors.map(s=><option key={s.id} value={s.id}>{s.name}</option>)}</select></Field>
<Field label="Acrescentar abrangência territorial documentada"><select className={control} value="" onChange={e=>{if(e.target.value && !draft.territory_ids.includes(e.target.value))change('territory_ids',[...draft.territory_ids,e.target.value]);}}><option value="">Seleccionar território existente</option>{territories.map(t=><option key={t.id} value={t.id}>{t.name} · {t.location_type}</option>)}</select></Field>
<p>Seleccione apenas a abrangência que pretende divulgar e cuja origem está identificada. Abrangência não comprova implantação ou disponibilidade de terreno. Sem selecção, a localização fica por indicar.</p>
<ul>{draft.territory_ids.map(id=><li key={id}>{territories.find(t=>t.id===id)?.name ?? 'Referência anterior indisponível'} <button type="button" className="underline" onClick={()=>change('territory_ids',draft.territory_ids.filter(t=>t!==id))}>Remover abrangência</button></li>)}</ul>
<button className={button} disabled={busy || needsMfa}>{publicNow ? 'Rever actualização do resumo' : 'Preparar prévia de divulgação'}</button>
{publicNow && <button type="button" className={`${button} ml-3`} disabled={busy || needsMfa} onClick={()=>void prepare('withdraw')}>Preparar retirada do resumo</button>}
</form>
{preview && <section className="space-y-4 rounded border p-5" aria-label="Prévia da divulgação">
<h2 className="text-xl font-semibold">{preview.action === 'withdraw' ? 'Rever retirada do catálogo' : 'Prévia do conteúdo público'}</h2>
<p><strong>{preview.projection.maturity_label}</strong></p>
<h3>{preview.projection.title}</h3><p>{preview.projection.summary}</p><p className="whitespace-pre-wrap">{preview.projection.description}</p>
<p>Sector: {sectors.find(s=>s.id===preview.projection.sector_id)?.name ?? 'Por indicar'}</p>
<ul>{preview.projection.territories.map(t=><li key={t.id}>{t.label}</li>)}</ul>
{!preview.projection.territories.length && <p>Abrangência territorial por indicar.</p>}
<p>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.'}</p>
<Field label="Motivo da divulgação, alteração ou retirada"><textarea required className={control} minLength={10} maxLength={1000} value={reason} onChange={e=>setReason(e.target.value)}/></Field>
<label className="block"><input type="checkbox" checked={confirmed} onChange={e=>setConfirmed(e.target.checked)}/> Confirmo o conteúdo público desta prévia e a operação indicada.</label>
<button className={button} disabled={busy || !confirmed || reason.trim().length<10} onClick={()=>void publish()}>{preview.action === 'withdraw' ? 'Confirmar retirada do resumo' : 'Confirmar divulgação do resumo'}</button>
<button className="ml-3 underline" disabled={busy} onClick={()=>{setPreview(null);setConfirmed(false);}}>Cancelar prévia</button>
</section>}
<section aria-label="Histórico da divulgação"><h2 className="text-xl font-semibold">Histórico da divulgação</h2><ul>{history.map(h=><li key={h.id}>{labels[h.action] ?? h.action} · {h.occurred_at} · responsável {h.actor_user_id}</li>)}</ul></section>
</Management>;
}
+14 -13
View File
@@ -27,6 +27,10 @@ type SecurityEntry = {
};
type Pair = { first_user_id: number; second_user_id: number; version: number };
type OperatorOrganization = { id: string; name: string; nuit: string; contact: string; director: string };
type StaffAccount = {id: number; name: string; email: string; verified: boolean; mfa: boolean};
function AccountPicker({accounts, value, onChange}: {accounts: StaffAccount[]; value: string; onChange: (value: string)=>void}) {
return <select required className={control} value={value} onChange={e=>onChange(e.target.value)}><option value="">Seleccionar conta existente</option>{accounts.map(a=><option key={a.id} value={a.id}>{a.name} — {a.email}{!a.verified ? ' — email por verificar' : !a.mfa ? ' — MFA por concluir' : ''}</option>)}</select>;
}
function OperatorOrganizationForm() {
const [operationKey] = useState(createOperationKey);
@@ -60,7 +64,7 @@ function OperatorOrganizationForm() {
</section>;
}
function InternalStaffGrant({ types, organizations, operators }: { types: string[]; organizations: { id: string; name: string }[]; operators: OperatorOrganization[] }) {
function InternalStaffGrant({ types, organizations, operators, accounts }: { types: string[]; organizations: { id: string; name: string }[]; operators: OperatorOrganization[]; accounts: StaffAccount[] }) {
const [operationKey, setOperationKey] = useState(createOperationKey);
const [subject, setSubject] = useState('');
const [role, setRole] = useState('promoter');
@@ -86,7 +90,7 @@ function InternalStaffGrant({ types, organizations, operators }: { types: string
<h2 className="text-xl font-semibold">Atribuir função interna do portal</h2>
<p>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.</p>
<form className="max-w-2xl space-y-3" onSubmit={e => { e.preventDefault(); void save(); }}>
<label className="block">Conta da pessoa designada<input required type="number" min={1} className={control} value={subject} onChange={e => setSubject(e.target.value)} /></label>
<label className="block">Conta da pessoa designada<AccountPicker accounts={accounts} value={subject} onChange={setSubject}/></label>
<label className="block">Função interna<select aria-label="Função interna" required className={control} value={role} onChange={e => setRole(e.target.value)}>{['promoter', 'validator', 'publisher'].map(v => <option key={v} value={v}>{v === 'promoter' ? 'Promotor' : v === 'validator' ? 'Validador' : 'Publicador'}</option>)}</select></label>
<label className="block">Vínculo à organização operadora<select aria-label="Vínculo à organização operadora" required className={control} value={operator} onChange={e => setOperator(e.target.value)}><option value="">Selecionar organização</option>{operators.map(o => <option key={o.id} value={o.id}>{o.name} — NUIT {o.nuit}</option>)}</select></label>
{role !== 'promoter' && <label className="block">Âmbito da função interna<select aria-label="Âmbito da função interna" className={control} value={scope} onChange={e => setScope(e.target.value)}><option value="">Todas as organizações para as categorias selecionadas</option>{organizations.map(o => <option key={o.id} value={o.id}>{o.name}</option>)}</select></label>}
@@ -103,9 +107,11 @@ function InternalStaffGrant({ types, organizations, operators }: { types: string
function Grant({
types,
organizations,
accounts,
}: {
types: string[];
organizations: { id: string; name: string }[];
accounts: StaffAccount[];
}) {
const [user, setUser] = useState('');
const [role, setRole] = useState('validator');
@@ -172,15 +178,8 @@ function Grant({
}}
>
<label className="block">
ID da conta existente
<input
className={control}
type="number"
min={1}
required
value={user}
onChange={(e) => setUser(e.target.value)}
/>
Conta existente
<AccountPicker accounts={accounts} value={user} onChange={setUser}/>
</label>
<label className="block">
Papel
@@ -590,7 +589,9 @@ export default function Administration({
securityRequests,
operatorOrganizations,
internalTypes,
staffAccounts,
}: {
staffAccounts: StaffAccount[];
operatorOrganizations: OperatorOrganization[];
internalTypes: string[];
actorId: number;
@@ -652,8 +653,8 @@ export default function Administration({
))}
</section>
<OperatorOrganizationForm />
<InternalStaffGrant types={internalTypes} organizations={organizations} operators={operatorOrganizations} />
<Grant types={types} organizations={organizations} />
<InternalStaffGrant types={internalTypes} organizations={organizations} operators={operatorOrganizations} accounts={staffAccounts} />
<Grant types={types} organizations={organizations} accounts={staffAccounts} />
<Security
pair={pair}
entries={securityRequests}
+3
View File
@@ -173,6 +173,9 @@ Route::middleware(['auth', 'verified', EnsurePortalProfiles::class])->group(func
Route::get('/minhas-oportunidades/{id}/classification', [OpportunityClassificationController::class, 'index'])->whereNumber('id');
Route::post('/minhas-oportunidades/{id}/classification/review', [OpportunityClassificationController::class, 'review'])->whereNumber('id');
Route::post('/minhas-oportunidades/{id}/workflow', [$profiles, 'workflow'])->whereNumber('id');
Route::get('/minhas-oportunidades/{id}/divulgacao', [\App\Http\Controllers\OpportunityPromotionController::class, 'show'])->whereNumber('id');
Route::post('/minhas-oportunidades/{id}/divulgacao/previa', [\App\Http\Controllers\OpportunityPromotionController::class, 'preview'])->whereNumber('id');
Route::post('/minhas-oportunidades/{id}/divulgacao/confirmar', [\App\Http\Controllers\OpportunityPromotionController::class, 'confirm'])->whereNumber('id');
Route::post('/minhas-oportunidades/{id}/contacts', [$profiles, 'contact'])->whereNumber('id');
Route::get('/minhas-oportunidades/{id}/resources', [OpportunityResourceController::class, 'index'])->whereNumber('id');
Route::post('/minhas-oportunidades/{id}/library-support', [OpportunityResourceController::class, 'reuseLibrary'])->whereNumber('id');
+170
View File
@@ -0,0 +1,170 @@
<?php
namespace Tests\Feature;
use App\Models\Opportunity;
use App\Models\User;
use App\Services\OpportunityManagement;
use App\Services\PortalProfileAdministration;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Tests\TestCase;
final class OpportunityPromotionTest extends TestCase
{
use DatabaseTransactions;
private User $actor;
private Opportunity $record;
private string $org;
protected function setUp(): void
{
parent::setUp();
$target = DB::selectOne('select current_database() as name, inet_server_port() as port');
$this->assertSame('simulator_fiscal_delta_20261021_test', $target->name);
$this->assertSame(55441, (int) $target->port);
$this->withoutVite();
config(['portal.profile_access_enabled' => true, 'portal.managers' => []]);
$this->actor = User::factory()->create();
$this->actor->forceFill(['two_factor_secret' => Crypt::encryptString('JBSWY3DPEHPK3PXP'), 'two_factor_confirmed_at' => now()])->save();
$this->org = (string) Str::uuid();
DB::table('organizations')->insert(['id' => $this->org, 'name' => 'SYNTHETIC QA Tempo operator', 'external_id' => '000000000', 'organization_type' => 'private']);
DB::table('audit_events')->insert(['source_system' => 'portal-profiles-v1', 'entity_type' => 'authorization', 'action' => 'operator_organization_registered', 'data_classification' => 'restricted',
'after_data' => json_encode(['organization_id' => $this->org, 'organization' => ['name' => 'SYNTHETIC QA Tempo operator', 'nuit' => '000000000'], 'source_reference' => 'Synthetic QA designation only'])]);
app(PortalProfileAdministration::class)->insertGrant((string) Str::uuid(), User::factory()->create(), ['user_id' => $this->actor->id,
'role' => 'promoter', 'organization_id' => $this->org, 'opportunity_types' => OpportunityManagement::CATEGORIES, 'responsibilities' => [], 'source' => 'internal-portal-designation']);
$this->record = new Opportunity;
$this->record->forceFill(['title' => 'PRIVATE TITLE', 'slug' => 'qa-promo-'.Str::uuid(), 'reference' => 'QA-'.Str::uuid(), 'status' => 'draft',
'opportunity_category' => 'investment_project', 'owner_user_id' => $this->actor->id, 'organization_id' => $this->org, 'management_version' => 1,
'promoter_name' => 'PRIVATE CONTACT', 'description' => 'PRIVATE CONTENT', 'estimated_investment_amount' => '123456.00',
'workflow_context' => ['operator_organization_id' => $this->org, 'contributors' => [$this->actor->id]],
'management_draft' => json_encode(['canonical' => ['official_title' => 'PRIVATE TITLE', 'contact' => 'PRIVATE CONTACT', 'documents' => ['PRIVATE DOCUMENT']]])])->save();
$this->login($this->actor);
}
private function login(User $actor): void
{
$this->actingAs($actor)->withSession(['portal.mfa_actor' => $actor->id, 'portal.mfa_at' => time(), 'portal.access_revision' => (int) $actor->portal_access_revision]);
}
private function base(): string { return '/minhas-oportunidades/'.$this->record->id.'/divulgacao'; }
private function data(array $overrides = []): array
{
return [...['etag' => app(OpportunityManagement::class)->etag($this->record), 'action' => 'disclose', 'title' => 'SYNTHETIC QA public summary',
'summary' => 'Synthetic QA opportunity in preparation, no real investment.', 'description' => 'Only this deliberately selected public text.',
'source_reference' => 'Synthetic source provided for QA', 'edition' => 'QA-1', 'source_date' => '2026-10-22',
'limitations' => 'Financing and implementation not established.', 'territory_ids' => []], ...$overrides];
}
private function preview(array $overrides = []): array { return $this->postJson($this->base().'/previa', $this->data($overrides))->assertOk()->json(); }
private function confirm(array $preview, ?string $key = null): array
{
$data = ['token' => $preview['token'], 'operation_key' => $key ?? (string) Str::uuid(), 'confirm' => true, 'reason' => 'Synthetic QA operation reviewed in preview'];
$this->postJson($this->base().'/confirmar', $data)->assertOk();
return $data;
}
public function test_preview_publish_update_withdraw_and_redisclose_preserve_private_draft_and_audit(): void
{
$file = (string) Str::uuid();
$attachment = (string) Str::uuid();
DB::table('documents')->insert(['id' => $file, 'file_name' => 'PRIVATE-QA-DOCUMENT.txt', 'storage_key' => 'private-qa/'.$file,
'media_type' => 'text/plain', 'sha256' => hash('sha256', 'private QA only'), 'size_bytes' => 15,
'uploaded_by' => $this->actor->id, 'status' => 'quarantined', 'data_classification' => 'internal']);
DB::table('opportunity_documents')->insert(['id' => $attachment, 'opportunity_id' => $this->record->id, 'document_id' => $file,
'version' => 1, 'created_by' => $this->actor->id, 'payload' => json_encode(['title' => 'PRIVATE DOCUMENT', 'visibility' => 'internal'])]);
$documentBefore = DB::table('documents')->where('id', $file)->first();
$attachmentBefore = DB::table('opportunity_documents')->where('id', $attachment)->first();
DB::table('opportunity_profiles')->insert(['opportunity_id' => $this->record->id, 'business_model' => 'PRIVATE PROFILE', 'source_reference' => 'PRIVATE SOURCE']);
$before = $this->record->refresh()->getRawOriginal();
$private = $before['management_draft'];
$preview = $this->preview();
$this->assertSame($before, $this->record->fresh()->getRawOriginal());
$this->assertFalse($preview['writes']);
$this->assertStringContainsString('Em preparação', $preview['projection']['description']);
$this->assertStringNotContainsString('PRIVATE', json_encode($preview['projection']));
$key = (string) Str::uuid();
$request = $this->confirm($preview, $key);
$this->postJson($this->base().'/confirmar', $request)->assertOk()->assertJsonPath('replayed', true);
$this->record->refresh();
$this->assertSame('draft', $this->record->workflow_context['working_state']);
$this->assertSame($private, $this->record->management_draft);
$this->assertSame('PRIVATE CONTACT', app(OpportunityManagement::class)->values($this->record)['promoter_name']);
$public = $this->getJson('/api/v1/opportunities/'.$this->record->slug)->assertOk();
$this->assertStringNotContainsString('PRIVATE', $public->getContent());
$public->assertJsonPath('data.maturity_label', 'Em preparação')->assertJsonPath('data.estimated_investment_amount', null);
$context = app(\App\Services\InvestmentCatalogue::class)->context($this->record->id);
$this->assertNull($context['profile']);
$this->assertSame([], $context['source_evidence']);
$this->assertStringNotContainsString('PRIVATE', json_encode($context));
$this->confirm($this->preview(['summary' => 'Updated synthetic QA summary.']));
$this->getJson('/api/v1/opportunities/'.$this->record->slug)->assertOk()->assertJsonPath('data.summary', 'Updated synthetic QA summary.');
$this->get($this->base())->assertOk();
$this->confirm($this->preview(['action' => 'withdraw']));
$this->getJson('/api/v1/opportunities/'.$this->record->slug)->assertNotFound();
$this->get($this->base())->assertOk();
$this->confirm($this->preview());
$this->getJson('/api/v1/opportunities/'.$this->record->slug)->assertOk();
$events = DB::table('audit_events')->where('source_system', 'tempo-preparation-publication')->where('after_data->record_id', $this->record->id)->get();
$this->assertCount(4, $events);
$this->assertContains('promotional_summary_withdrawn', $events->pluck('action')->all());
foreach ($events as $event) { $this->assertSame($this->actor->id, (int) $event->actor_user_id); $this->assertFalse(json_decode($event->after_data, true)['execution_approved']); }
$this->assertEquals($documentBefore, DB::table('documents')->where('id', $file)->first());
$this->assertEquals($attachmentBefore, DB::table('opportunity_documents')->where('id', $attachment)->first());
}
public function test_stale_tampered_expired_and_foreign_preview_do_not_write(): void
{
$preview = $this->preview();
$payload = ['token' => $preview['token'].'broken', 'operation_key' => (string) Str::uuid(), 'confirm' => true, 'reason' => 'Synthetic QA invalid confirmation'];
$this->postJson($this->base().'/confirmar', $payload)->assertUnprocessable();
$this->record->title = 'Concurrent private edit'; $this->record->save();
$this->postJson($this->base().'/confirmar', [...$payload, 'token' => $preview['token']])->assertConflict();
$fresh = $this->preview();
$this->travel(16)->minutes();
$this->withSession(['portal.mfa_at' => time()]);
$this->postJson($this->base().'/confirmar', [...$payload, 'token' => $fresh['token']])->assertConflict();
$this->travelBack();
$this->login(User::factory()->create());
$this->get($this->base())->assertForbidden();
$this->postJson($this->base().'/confirmar', [...$payload, 'token' => $fresh['token']])->assertForbidden();
$this->assertSame('draft', $this->record->fresh()->status);
}
public function test_external_promoter_and_unrelated_internal_actor_cannot_disclose_and_no_ordinary_publication_is_overwritten(): void
{
$other = User::factory()->create();
$this->login($other);
$this->postJson($this->base().'/previa', $this->data())->assertForbidden();
$this->login($this->actor);
DB::table('memberships')->insert(['id' => (string) Str::uuid(), 'organization_id' => $this->org, 'user_id' => $this->actor->id, 'role' => 'officer']);
DB::table('portal_role_assignments')->where('user_id', $this->actor->id)->update(['source' => 'approved-external-promoter']);
$this->postJson($this->base().'/previa', $this->data())->assertForbidden();
DB::table('portal_role_assignments')->where('user_id', $this->actor->id)->update(['source' => 'internal-portal-designation']);
$this->withSession(['portal.mfa_at' => time() - 901]);
$this->postJson($this->base().'/previa', $this->data())->assertForbidden();
$this->login($this->actor);
$this->record->forceFill(['status' => 'published', 'published_at' => now(), 'workflow_context' => [...$this->record->workflow_context, 'publication' => ['visibility' => 'public']]])->save();
$this->get($this->base())->assertForbidden();
}
public function test_existing_publisher_scope_can_use_the_same_action_without_new_role_or_approval(): void
{
$publisher = User::factory()->create();
$publisher->forceFill(['two_factor_secret' => Crypt::encryptString('JBSWY3DPEHPK3PXP'), 'two_factor_confirmed_at' => now()])->save();
app(PortalProfileAdministration::class)->insertGrant((string) Str::uuid(), $this->actor, ['user_id' => $publisher->id,
'role' => 'publisher', 'organization_id' => $this->org, 'opportunity_types' => ['investment_project'], 'responsibilities' => [], 'source' => 'synthetic-approved-designation']);
$roles = DB::table('portal_role_assignments')->count();
$this->login($publisher);
$access = app(\App\Services\PortalProfileAccess::class);
$this->assertTrue($access->canView($publisher, $this->record), 'Publisher read scope');
$this->assertTrue($access->scoped($publisher, 'publisher', $this->org, 'investment_project'), 'Publisher scope');
$this->assertTrue(app(\App\Services\OpportunityPromotion::class)->allowed($publisher, $this->record), 'Promotional action scope');
$this->confirm($this->preview());
$this->assertSame($roles, DB::table('portal_role_assignments')->count());
$this->assertArrayNotHasKey('approval', $this->record->fresh()->workflow_context);
}
public function test_profile_directory_uses_existing_accounts_without_exposing_authentication_secrets(): void
{
$other = User::factory()->create();
DB::table('portal_superuser_pair')->updateOrInsert(['id' => 1], ['first_user_id' => $this->actor->id, 'second_user_id' => $other->id, 'approved_by' => $other->id]);
$this->get('/perfis/gestao')->assertOk()->assertInertia(fn (\Inertia\Testing\AssertableInertia $page) => $page
->has('staffAccounts')->where('staffAccounts', fn ($rows) => collect($rows)->contains(fn ($row) => $row['id'] === $this->actor->id && $row['email'] === $this->actor->email && $row['verified'] && $row['mfa'])
&& collect($rows)->every(fn ($row) => !isset($row['password']) && !isset($row['two_factor_secret']))));
}
}