From 9ab721c97f8197f8358d14cde308981e8b0bcce4 Mon Sep 17 00:00:00 2001 From: nala Date: Tue, 20 Oct 2026 14:44:31 +0200 Subject: [PATCH] Reconciliar fontes instaladas 423bd512d8c5 com main --- .../Commands/CheckDevelopmentEnvironment.php | 79 +--- .../Api/V1/InterestRequestController.php | 21 +- .../Controllers/DocumentMalwareController.php | 4 +- .../EditorialArticleController.php | 2 + .../ExistingBusinessController.php | 2 +- .../InvestmentSubmissionController.php | 26 +- .../Controllers/InvestmentZoneController.php | 4 +- .../OpportunityResourceController.php | 12 +- .../PortalManagementController.php | 44 +- .../Controllers/PortalServicesController.php | 45 +- .../Controllers/VirtualDeskController.php | 31 +- .../Middleware/EnsureDocumentManagement.php | 28 ++ .../Middleware/EnsureHomologationAccess.php | 37 ++ app/Http/Middleware/EnsureScopedEditorial.php | 25 + .../EnsureScopedPortalAdministration.php | 31 ++ app/Http/Middleware/HandleInertiaRequests.php | 11 + .../VerifiedOrSimpleParticipation.php | 27 ++ .../Requests/StoreInterestRequestRequest.php | 2 +- app/Policies/ExistingBusinessPolicy.php | 13 +- app/Services/BusinessFormSchema.php | 15 +- app/Services/DeclaredOriginStatistics.php | 10 +- app/Services/DocumentManagementAccess.php | 86 ++++ .../EconomicReferenceInstallation.php | 2 +- app/Services/ExistingBusinessIntake.php | 10 +- .../ExistingBusinessProfileCategory.php | 45 ++ app/Services/InterestWorkflow.php | 1 + ...mentZoneOperationalDetailsInstallation.php | 2 +- .../InvestmentZoneProfileInstallation.php | 2 +- app/Services/InvestorContext.php | 24 +- app/Services/LegacyContent.php | 42 +- app/Services/Malware/ClamDScanner.php | 19 +- app/Services/OpportunityCanonicalContract.php | 33 +- app/Services/OpportunityCategoryFields.php | 9 +- app/Services/OpportunityCompleteness.php | 2 +- .../OpportunityInitiativeApplicability.php | 94 ++++ app/Services/OpportunityLibrarySupport.php | 100 ++++ app/Services/OpportunityResources.php | 9 + .../OpportunityTerritorialPresentation.php | 28 ++ app/Services/PortalProfileInstallation.php | 24 +- app/Services/ScopedPortalAdministration.php | 50 ++ app/Services/VerifiedDocumentBinary.php | 3 +- app/Services/VirtualDesk.php | 6 +- bootstrap/app.php | 7 +- config/homologation_editorial.php | 157 +++++++ config/legacy_content_aliases.php | 127 +++++ config/portal.php | 14 + config/portal_profiles.php | 3 + config/services.php | 1 + ...00_add_investment_zone_editorial_draft.php | 2 +- ...dd_investment_zone_operational_details.php | 2 +- ...create_investor_navigation_preferences.php | 2 +- ...18_160000_add_portal_editorial_content.php | 2 +- deploy/homologacao/reconciled-baseline.json | 442 ++++++++++++++++++ resources/js/app.tsx | 2 +- resources/js/components/app-logo.tsx | 13 +- resources/js/components/context-documents.tsx | 54 +++ resources/js/components/country-select.tsx | 1 + .../js/components/investment-map-panel.tsx | 12 +- .../js/components/investment-territory.tsx | 35 +- .../opportunity-canonical-fields.tsx | 133 +++++- .../components/opportunity-classification.tsx | 90 +++- .../components/opportunity-library-reuse.tsx | 121 +++++ .../components/partnership-search-results.tsx | 48 +- resources/js/components/password-input.tsx | 2 +- .../js/components/portal-grouped-menu.tsx | 13 +- .../js/components/portal-product-identity.tsx | 18 + resources/js/components/search-feedback.tsx | 46 ++ resources/js/components/search-state.tsx | 5 + .../js/components/structure-geography.tsx | 2 +- resources/js/components/tempo-footer.tsx | 23 +- resources/js/hooks/use-interest-draft.ts | 16 + .../js/layouts/auth/auth-simple-layout.tsx | 8 +- resources/js/layouts/portal-navigation.tsx | 7 + resources/js/lib/investor-context.ts | 18 + resources/js/lib/participation-identity.ts | 5 + resources/js/lib/portal-api.ts | 4 + resources/js/lib/portal-navigation.ts | 2 +- resources/js/lib/project-dossier-document.ts | 27 +- resources/js/lib/project-dossier-report.ts | 41 +- resources/js/pages/PortalInvestidor.tsx | 87 +++- resources/js/pages/auth/login.tsx | 28 +- resources/js/pages/auth/register.tsx | 30 +- resources/js/pages/desk/request.tsx | 4 +- .../js/pages/investment/business-interest.tsx | 10 +- .../js/pages/investment/business-wizard.tsx | 43 +- resources/js/pages/investment/business.tsx | 1 + resources/js/pages/investment/catalogue.tsx | 39 +- resources/js/pages/investment/context.tsx | 20 +- resources/js/pages/investment/detail.tsx | 11 +- resources/js/pages/investment/submission.tsx | 18 +- resources/js/pages/investment/zones.tsx | 10 +- .../js/pages/investor-path/context-start.tsx | 2 + resources/js/pages/investor-path/context.tsx | 2 + resources/js/pages/investor-path/show.tsx | 4 + resources/js/pages/land-assistance/print.tsx | 8 +- .../js/pages/management/desk-assignments.tsx | 15 + .../js/pages/management/opportunity-draft.tsx | 23 +- .../management/opportunity-resources.tsx | 218 ++++++++- .../management/territory-associations.tsx | 14 +- resources/js/pages/portal/manage.tsx | 13 +- resources/js/pages/portal/services.tsx | 10 + resources/views/desk/receipt.blade.php | 46 ++ resources/views/errors/403.blade.php | 2 + routes/api.php | 2 +- routes/investment-zones.php | 2 +- routes/investment.php | 4 +- routes/virtual-desk.php | 3 +- routes/web.php | 26 +- 108 files changed, 2766 insertions(+), 494 deletions(-) create mode 100644 app/Http/Middleware/EnsureDocumentManagement.php create mode 100644 app/Http/Middleware/EnsureHomologationAccess.php create mode 100644 app/Http/Middleware/EnsureScopedEditorial.php create mode 100644 app/Http/Middleware/EnsureScopedPortalAdministration.php create mode 100644 app/Http/Middleware/VerifiedOrSimpleParticipation.php create mode 100644 app/Services/DocumentManagementAccess.php create mode 100644 app/Services/ExistingBusinessProfileCategory.php create mode 100644 app/Services/OpportunityInitiativeApplicability.php create mode 100644 app/Services/OpportunityLibrarySupport.php create mode 100644 app/Services/OpportunityTerritorialPresentation.php create mode 100644 app/Services/ScopedPortalAdministration.php create mode 100644 config/homologation_editorial.php create mode 100644 config/legacy_content_aliases.php create mode 100644 deploy/homologacao/reconciled-baseline.json create mode 100644 resources/js/components/context-documents.tsx create mode 100644 resources/js/components/opportunity-library-reuse.tsx create mode 100644 resources/js/components/portal-product-identity.tsx create mode 100644 resources/js/components/search-feedback.tsx create mode 100644 resources/js/components/search-state.tsx create mode 100644 resources/js/hooks/use-interest-draft.ts create mode 100644 resources/js/lib/participation-identity.ts create mode 100644 resources/js/pages/management/desk-assignments.tsx create mode 100644 resources/views/desk/receipt.blade.php create mode 100644 resources/views/errors/403.blade.php diff --git a/app/Console/Commands/CheckDevelopmentEnvironment.php b/app/Console/Commands/CheckDevelopmentEnvironment.php index 1acc5478..240becbd 100644 --- a/app/Console/Commands/CheckDevelopmentEnvironment.php +++ b/app/Console/Commands/CheckDevelopmentEnvironment.php @@ -5,24 +5,15 @@ namespace App\Console\Commands; use Illuminate\Console\Command; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Schema; -use Illuminate\Support\Sleep; -use Throwable; class CheckDevelopmentEnvironment extends Command { - protected $signature = 'portal:dev-check {--wait=0 : Segundos para aguardar uma falha transitória do PostgreSQL}'; + protected $signature = 'portal:dev-check'; protected $description = 'Verifica o arranque local sem alterar dados ou mostrar credenciais'; public function handle(): int { - $wait = filter_var($this->option('wait'), FILTER_VALIDATE_INT, ['options' => ['min_range' => 0, 'max_range' => 3600]]); - if ($wait === false) { - $this->error('--wait deve ser um inteiro entre 0 e 3600 segundos.'); - - return self::INVALID; - } - $checks = [ 'PHP >= 8.4.1' => version_compare(PHP_VERSION, '8.4.1', '>='), 'Driver PostgreSQL activo' => extension_loaded('pdo_pgsql'), @@ -34,17 +25,14 @@ class CheckDevelopmentEnvironment extends Command ]; if ($checks['Driver PostgreSQL activo'] && $checks['Base PostgreSQL configurada']) { - $checks['Ligação à base de dados'] = $this->checkConnection($wait); - } - - if ($checks['Ligação à base de dados'] ?? false) { try { + DB::selectOne('SELECT 1'); + $checks['Ligação à base de dados'] = true; $checks['Extensão PostGIS instalada'] = (bool) DB::selectOne("SELECT EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'postgis') AS installed")->installed; $checks['Tabelas principais instaladas'] = collect(['migrations', 'users', 'sectors', 'opportunities', 'sessions', 'cache', 'jobs']) ->every(fn (string $table): bool => Schema::hasTable($table)); - } catch (Throwable) { - $checks['Esquema da base de dados verificável'] = false; - $this->warn('A ligação foi estabelecida, mas a verificação do esquema falhou. Verifique as permissões e a preparação QA.'); + } catch (\Throwable) { + $checks['Ligação à base de dados'] = false; } } @@ -60,61 +48,4 @@ class CheckDevelopmentEnvironment extends Command return self::SUCCESS; } - - private function checkConnection(int $wait): bool - { - $deadline = microtime(true) + $wait; - - while (true) { - try { - DB::selectOne('SELECT 1'); - - return true; - } catch (Throwable $error) { - [$reason, $retryable] = $this->connectionFailure($error); - $remaining = $deadline - microtime(true); - if (! $retryable || $remaining <= 0) { - $this->warn($reason); - if ($retryable) { - $this->warn('Inicie Docker Desktop e execute ./deploy/local/start.ps1. Consulte os registos com docker compose --env-file .env -f deploy/local/compose.yaml logs --tail 40 postgres.'); - } - - return false; - } - - $this->line('[AGUARDA] '.$reason.' Nova tentativa; prazo restante: '.(int) ceil($remaining).' s.'); - DB::purge(); - Sleep::for(min(5, $remaining))->seconds(); - } - } - } - - /** @return array{string, bool} */ - private function connectionFailure(Throwable $error): array - { - // Inspect the chain internally; never print exception messages, DSNs or credentials. - $messages = ''; - do { - $messages .= ' '.strtolower($error->getMessage()).' '.strtolower((string) $error->getCode()); - $error = $error->getPrevious(); - } while ($error !== null); - - if (str_contains($messages, '28p01') || str_contains($messages, '28000') - || str_contains($messages, 'password authentication failed') || str_contains($messages, 'no pg_hba.conf entry')) { - return ['Autenticação PostgreSQL recusada. Verifique as credenciais e as regras de acesso; aguardar não corrige este erro.', false]; - } - if (str_contains($messages, '3d000') || preg_match('/database .* does not exist/', $messages)) { - return ['A base PostgreSQL configurada não existe. Verifique a configuração e a preparação QA.', false]; - } - if (str_contains($messages, '57p03') || str_contains($messages, 'recovery mode') - || str_contains($messages, 'not yet accepting connections') || str_contains($messages, 'database system is starting up')) { - return ['PostgreSQL em arranque ou recuperação; aguarde a conclusão sem remover o volume.', true]; - } - if (preg_match('/\b08[0-9a-z]{3}\b/', $messages) || str_contains($messages, 'connection refused') - || str_contains($messages, '57p01') || str_contains($messages, '57p02')) { - return ['PostgreSQL indisponível. Verifique se Docker Desktop e o contentor local estão iniciados.', true]; - } - - return ['Falha PostgreSQL não transitória. Verifique a configuração e os registos do servidor.', false]; - } } diff --git a/app/Http/Controllers/Api/V1/InterestRequestController.php b/app/Http/Controllers/Api/V1/InterestRequestController.php index 38eb56ca..d59eeae5 100644 --- a/app/Http/Controllers/Api/V1/InterestRequestController.php +++ b/app/Http/Controllers/Api/V1/InterestRequestController.php @@ -23,8 +23,9 @@ class InterestRequestController extends Controller { public function store(StoreInterestRequestRequest $request, InterestSubmission $submission): JsonResponse { - $validated = $request->validated(); + $validated = [...$request->validated(), '_actor_id' => $request->user()->id]; $key = $request->header('Idempotency-Key'); + abort_unless(is_string($key), 422, 'Identificador do envio em falta.'); Validator::make(['idempotency_key' => $key], ['idempotency_key' => [$key === null ? 'nullable' : 'required', 'uuid']])->validate(); try { @@ -53,7 +54,7 @@ class InterestRequestController extends Controller 'phone' => $validated['phone'] ?? null, 'investor_type' => $validated['investorType'], 'country' => $validated['country'], - 'metadata' => ['declared_origin_country_code' => $validated['declared_origin_country_code'] ?? null], + 'metadata' => ['portal_user_id' => $request->user()->id, 'declared_origin_country_code' => $validated['declared_origin_country_code'] ?? null], 'sector_interest' => $validated['sectorInterest'], 'opportunity_reference' => $validated['opportunityReference'] ?? null, 'investment_range' => $validated['investmentRange'] ?? null, @@ -77,6 +78,19 @@ class InterestRequestController extends Controller } } + $row = \App\Models\InvestmentSubmission::create([ + 'user_id' => $request->user()->id, 'opportunity_id' => $opportunity?->id, + 'interest_request_id' => $interest->id, 'status' => 'submitted', 'version' => 1, + 'payload' => ['simple_participation' => true, 'opportunity_title' => $opportunity?->title ?? $business?->title, 'opportunity_reference' => $opportunity?->reference, 'phone' => $validated['phone'] ?? null, 'declared_origin_country_code' => $validated['declared_origin_country_code'] ?? null, 'full_name' => $validated['fullName'], 'email' => $validated['email'], + 'objective' => $validated['message'], 'sector' => $validated['sectorInterest'], + 'country' => $validated['country'], 'existing_business_id' => $business?->id, + 'modality' => $validated['modality'] ?? null], + ]); + \Illuminate\Support\Facades\DB::table('investment_submission_events')->insert([ + 'submission_id' => $row->id, 'actor_id' => $request->user()->id, + 'action' => 'submitted', 'version' => 1, 'created_at' => now(), + ]); + $interest->forceFill(['metadata' => [...$interest->metadata, 'submission_id' => $row->id]])->save(); return $interest; }); @@ -85,7 +99,8 @@ class InterestRequestController extends Controller 'message' => 'Manifestação de interesse registada com sucesso.', 'data' => [ 'reference' => $interestRequest->reference, - 'status' => 'received', + 'status' => $interestRequest->status, + 'tracking_url' => '/investidor/manifestacoes', ], ], 201); } catch (IdempotencyConflict) { diff --git a/app/Http/Controllers/DocumentMalwareController.php b/app/Http/Controllers/DocumentMalwareController.php index b53ab6b2..1cabce3a 100644 --- a/app/Http/Controllers/DocumentMalwareController.php +++ b/app/Http/Controllers/DocumentMalwareController.php @@ -11,8 +11,8 @@ class DocumentMalwareController extends Controller { public function retry(Request $request, string $id): RedirectResponse { - DB::transaction(function () use ($id): void { - $document = DB::table('documents')->where('id', $id)->lockForUpdate()->first(); + DB::transaction(function () use ($id, $request): void { + $document = app(\App\Services\DocumentManagementAccess::class)->documents($request->user())->where('id', $id)->lockForUpdate()->first(); abort_unless($document && $document->status !== 'deleted' && $document->deleted_at === null, 404); abort_unless(in_array($document->malware_scan_status, ['pending', 'inconclusive', 'failed'], true), 422, 'Só é possível repetir uma análise pendente, inconclusiva ou falhada. Para substituir um ficheiro, carregue uma nova versão.'); diff --git a/app/Http/Controllers/EditorialArticleController.php b/app/Http/Controllers/EditorialArticleController.php index b3eda649..353b326e 100644 --- a/app/Http/Controllers/EditorialArticleController.php +++ b/app/Http/Controllers/EditorialArticleController.php @@ -78,6 +78,7 @@ class EditorialArticleController extends Controller public function edit(int $id) { $this->installed(); + app(\App\Services\DocumentManagementAccess::class)->assertEditableResource(request()->user(), $id); $r = DB::table('portal_resources')->find($id); abort_unless($r, 404); $content = $r->editorial_content ? json_decode($r->editorial_content, true) : null; @@ -108,6 +109,7 @@ class EditorialArticleController extends Controller DB::transaction(function () use ($request, $id, $data) { $r = DB::table('portal_resources')->where('id', $id)->lockForUpdate()->first(); abort_unless($r, 404); + app(\App\Services\DocumentManagementAccess::class)->assertEditableResource($request->user(), $id); abort_unless((int) $r->metadata_version === (int) $data['metadata_version'], 409, 'O artigo foi alterado noutra sessão. Recarregue antes de editar.'); if (! empty($data['image_resource_id'])) { abort_unless($this->publicResources()->where('r.id', $data['image_resource_id'])->whereIn('d.media_type', ['image/png', 'image/jpeg', 'image/webp'])->exists(), 422, 'Escolha uma imagem pública disponível na biblioteca.'); diff --git a/app/Http/Controllers/ExistingBusinessController.php b/app/Http/Controllers/ExistingBusinessController.php index 6b710fbb..31ab486d 100644 --- a/app/Http/Controllers/ExistingBusinessController.php +++ b/app/Http/Controllers/ExistingBusinessController.php @@ -67,7 +67,7 @@ class ExistingBusinessController extends Controller $business = app(BusinessRepository::class)->find($id); Gate::authorize('view', $business); app(CompatibilityShadow::class)->schedule($id); - $props = ['countries' => app(CountryCatalogue::class)->all(), 'sectors' => Sector::choices()->orderBy('name')->get(['id', 'name', 'slug']), 'formSchema' => BusinessFormSchema::fields(), 'record' => $this->record($business), 'canEdit' => $request->user()->can('update', $business), + $props = ['countries' => app(CountryCatalogue::class)->all(), 'sectors' => Sector::choices()->orderBy('name')->get(['id', 'name', 'slug']), 'formSchema' => BusinessFormSchema::fields(), 'modalityFormType' => \App\Services\ExistingBusinessProfileCategory::formType($business->modality), 'record' => $this->record($business), 'canEdit' => $request->user()->can('update', $business), 'owner' => $request->user()->can('owns', $business), 'canReview' => $request->user()->can('review', $business), 'canTriage' => $request->user()->can('triage', $business), 'canApproveLevelOne' => $request->user()->can('approveLevelOne', $business), diff --git a/app/Http/Controllers/InvestmentSubmissionController.php b/app/Http/Controllers/InvestmentSubmissionController.php index 85bdbf29..1c70004b 100644 --- a/app/Http/Controllers/InvestmentSubmissionController.php +++ b/app/Http/Controllers/InvestmentSubmissionController.php @@ -31,7 +31,14 @@ class InvestmentSubmissionController extends Controller { public function index(Request $request): Response|JsonResponse { - $submissions = InvestmentSubmission::where('user_id', $request->user()->id)->latest()->paginate(20)->through(function ($row) { + $query = InvestmentSubmission::where('user_id', $request->user()->id); + if (! $request->user()->hasVerifiedEmail()) { + // The existing payload is encrypted; scope after decryption, never infer ownership from email. + $simpleIds = InvestmentSubmission::where('user_id', $request->user()->id)->where('status', 'submitted')->get(['id', 'payload']) + ->filter(fn ($row) => ($row->payload['simple_participation'] ?? false) === true)->modelKeys(); + $query->whereIn('id', $simpleIds); + } + $submissions = $query->latest()->paginate(20)->through(function ($row) { $interest = $row->interest_request_id ? InterestRequest::find($row->interest_request_id) : null; return ['id' => $row->id, 'status' => $row->status, 'version' => $row->version, 'created_at' => $row->created_at, @@ -118,29 +125,32 @@ class InvestmentSubmissionController extends Controller $target = $row->opportunity_id ? Opportunity::published()->find($row->opportunity_id) : null; $dossier = $row->dossier_id ? InvestmentPortfolioController::visible()->find($row->dossier_id) : null; $zoneOrigin = DB::table('investment_submission_zones')->where('submission_id', $id)->first(['zone_id', 'zone_version', 'intent']); - $boundCase = DB::table('desk_cases')->where('submission_id', $id)->where('user_id', $request->user()->id)->whereNotNull('journey_id')->first(); - $contextChoices = (int) $row->user_id === (int) $request->user()->id && $row->status === 'draft' && ! $boundCase + $contextAvailable = app(VirtualDesk::class)->installed(); + $boundCase = $contextAvailable ? DB::table('desk_cases')->where('submission_id', $id)->where('user_id', $request->user()->id)->whereNotNull('journey_id')->first() : null; + $contextChoices = $contextAvailable && (int) $row->user_id === (int) $request->user()->id && $row->status === 'draft' && ! $boundCase ? DB::table('desk_cases')->where('user_id', $request->user()->id)->whereNotNull('journey_id')->whereNull('closed_at')->whereNull('submission_id')->orderBy('title')->get(['id', 'title', 'version']) : []; - $data = ['contextDossierId' => $boundCase?->id, 'contextChoices' => $contextChoices, 'boundContextRevision' => app(InvestorContextConsumers::class)->submissionRevision($id), 'countries' => app(CountryCatalogue::class)->all(), 'submission' => $row->only(['id', 'payload', 'version', 'status', 'opportunity_id', 'dossier_id']), 'zone_origin' => $zoneOrigin, - 'target' => ($target ? $target->title : null) ?? ($dossier ? $dossier->title : null), 'references' => [ + $data = ['contextDossierId' => $boundCase?->id, 'contextChoices' => $contextChoices, 'boundContextRevision' => $contextAvailable ? app(InvestorContextConsumers::class)->submissionRevision($id) : 0, 'countries' => app(CountryCatalogue::class)->all(), 'submission' => $row->only(['id', 'payload', 'version', 'status', 'opportunity_id', 'dossier_id']), 'zone_origin' => $zoneOrigin, + 'target' => ($target ? $target->title : null) ?? ($dossier ? $dossier->title : null) ?? ($row->payload['opportunity_title'] ?? null), 'references' => [ ...$catalogue->references(), 'options' => DB::table('investment_reference_options')->orderBy('label')->get()], 'needs' => InvestmentSpecificNeed::where('submission_id', $id)->get(), 'attachments' => DB::table('investment_submission_attachments as a')->join('documents as d', 'd.id', '=', 'a.document_id') ->where('a.submission_id', $id)->whereNull('d.deleted_at')->get(['a.id', 'd.file_name']), - 'events' => DB::table('investment_submission_events')->where('submission_id', $id)->orderBy('id')->get(['action', 'version', 'created_at']), + 'events' => DB::table('investment_submission_events')->where('submission_id', $id)->orderBy('id')->get(['action', 'version', 'created_at']) + ->concat($interest ? DB::table('business_workflow_transitions')->where('interest_request_id', $interest->id)->orderBy('created_at')->get(['to_status', 'created_at'])->map(fn ($event) => ['action' => $event->to_status, 'version' => $row->version, 'created_at' => $event->created_at]) : [])->sortBy('created_at')->values(), 'revisions' => DB::table('investment_submission_revisions')->where('submission_id', $id)->orderByDesc('version')->get(['version', 'created_at']), 'messages' => DB::table('investment_messages')->where('submission_id', $id)->orderBy('created_at')->get(['id', 'sender_role', 'body', 'created_at'])->map(function ($message) { $message->body = Crypt::decryptString($message->body); return $message; }), - 'receipt' => $interest?->only(['reference', 'status']), 'management' => (int) $row->user_id !== $request->user()->id]; + 'receipt' => $interest?->only(['reference', 'status', 'next_action', 'next_action_due_at']), 'management' => (int) $row->user_id !== $request->user()->id]; return $request->expectsJson() ? response()->json($data) : Inertia::render('investment/submission', $data); } public function bindContext(Request $request, string $id): JsonResponse { + abort_unless(app(VirtualDesk::class)->installed(), 422, 'O vínculo ao percurso do investimento não está disponível neste destino. A manifestação e a sua origem permanecem guardadas.'); abort_if(array_diff(array_keys($request->all()), ['case_id', 'case_version', 'version', '_token']), 422, 'Parâmetros de associação inválidos.'); $data = $request->validate(['case_id' => 'required|uuid', 'case_version' => 'required|integer|min:1', 'version' => 'required|integer|min:1']); $result = DB::transaction(function () use ($request, $id, $data) { @@ -395,7 +405,7 @@ class InvestmentSubmissionController extends Controller private function event(InvestmentSubmission $row, Request $request, string $action): void { - $boundCase = DB::table('desk_cases')->where('submission_id', $row->id)->where('user_id', $request->user()->id)->whereNotNull('journey_id')->first(); + $boundCase = app(VirtualDesk::class)->installed() ? DB::table('desk_cases')->where('submission_id', $row->id)->where('user_id', $request->user()->id)->whereNotNull('journey_id')->first() : null; $binding = null; if ($boundCase) { $binding = app(InvestorContextConsumers::class)->submissionRevision($row->id); diff --git a/app/Http/Controllers/InvestmentZoneController.php b/app/Http/Controllers/InvestmentZoneController.php index 18f2a12a..b9c75bb4 100644 --- a/app/Http/Controllers/InvestmentZoneController.php +++ b/app/Http/Controllers/InvestmentZoneController.php @@ -98,7 +98,7 @@ class InvestmentZoneController extends Controller abort_if((! empty($filters['development_status']) || ! empty($filters['availability_status'])) && ! Schema::hasColumn('investment_zones', 'source_evidence'), 503, 'Os filtros de desenvolvimento e disponibilidade estarão disponíveis após a migração do catálogo de zonas.'); $path = app(InvestmentGeography::class)->selection($filters); - $publicColumns = ['version_no', 'id', 'code', 'name', 'name_en', 'category', 'physical_category', 'legal_regime', 'operational_status', 'development_status', 'availability_status', 'area_value', 'area_unit', 'area_components', 'source_evidence', 'activities', 'resources', 'aliases', 'data_as_of', 'sources_consulted_at', 'province', 'district', 'description', 'description_en', 'validation_status', 'updated_at']; + $publicColumns = ['primary_territory_id', 'version_no', 'id', 'code', 'name', 'name_en', 'category', 'physical_category', 'legal_regime', 'operational_status', 'development_status', 'availability_status', 'area_value', 'area_unit', 'area_components', 'source_evidence', 'activities', 'resources', 'aliases', 'data_as_of', 'sources_consulted_at', 'province', 'district', 'description', 'description_en', 'validation_status', 'updated_at']; if(app(\App\Services\InvestmentZoneOperationalDetails::class)->installed()){$publicColumns[]='operational_details';} $publicColumns = array_values(array_filter($publicColumns, fn ($column) => Schema::hasColumn('investment_zones', $column))); $rows = $zones->search($filters, $management)->orderBy('name')->orderBy('id') @@ -113,6 +113,8 @@ class InvestmentZoneController extends Controller ->when(! $management, fn ($q) => $q->where('zl.validation_status', 'validated')->where('g.data_classification', 'public')) ->orderBy('g.location_type')->orderBy('g.name')->get(['g.id', 'g.name', 'g.location_type', 'zl.scope_type'])); + if (! $management) { $zone->makeHidden('primary_territory_id'); } + return $management ? $zone : [...app(InvestmentZoneOperationalProfile::class)->publicDetails($zone->toArray()),'operational_details_public'=>app(\App\Services\InvestmentZoneOperationalDetails::class)->publicData($zone)]; }); $props = ['detailsInstallation'=>$management ? app(\App\Services\InvestmentZoneOperationalDetailsInstallation::class)->preview($request->user()) : null, 'installation' => $management ? app(InvestmentZoneProfileInstallation::class)->preview($request->user()) : null, 'records' => $rows, 'filters' => $filters, 'management' => $management, 'territorySelection' => $path, diff --git a/app/Http/Controllers/OpportunityResourceController.php b/app/Http/Controllers/OpportunityResourceController.php index cbb64482..b8389e08 100644 --- a/app/Http/Controllers/OpportunityResourceController.php +++ b/app/Http/Controllers/OpportunityResourceController.php @@ -98,7 +98,13 @@ class OpportunityResourceController extends Controller } } - return Inertia::render('management/opportunity-resources', ['opportunity' => $opportunity, 'etag' => app(OpportunityManagement::class)->etag($opportunity), 'resources' => $resources, 'existingLocations' => DB::table('opportunity_locations as l')->join('geo_locations as g', 'g.id', '=', 'l.geo_location_id')->where('opportunity_id', $id)->get(['g.id', 'g.name', 'g.location_type']), 'assessment' => $this->assessment($resources)]); + return Inertia::render('management/opportunity-resources', ['opportunity' => $opportunity, 'etag' => app(OpportunityManagement::class)->etag($opportunity), 'resources' => $resources, 'librarySources' => !app(\App\Services\PortalProfileAccess::class)->canEdit(request()->user(), $opportunity) ? [] : app(\App\Services\OpportunityLibrarySupport::class)->choices(request()->user()), 'existingLocations' => DB::table('opportunity_locations as l')->join('geo_locations as g', 'g.id', '=', 'l.geo_location_id')->where('opportunity_id', $id)->get(['g.id', 'g.name', 'g.location_type']), 'locationReference' => app(\App\Services\OpportunityTerritorialPresentation::class)->describe($opportunity, $resources['locations']), 'assessment' => $this->assessment($resources)]); + } + + public function reuseLibrary(Request $request, int $id): JsonResponse + { + $row = app(\App\Services\OpportunityLibrarySupport::class)->attach($request->user(), Opportunity::findOrFail($id), $request->all()); + return response()->json(['id' => $row->id, 'message' => 'Original associado como suporte privado, sem copiar ficheiros ou publicar.']); } public function save(Request $request, int $id, string $kind, OpportunityResources $service, ?string $resourceId = null): JsonResponse @@ -144,11 +150,15 @@ class OpportunityResourceController extends Controller { abort_unless(in_array($kind, ['documents', 'images'], true), 404); $opportunity = Opportunity::findOrFail($id); + app(\App\Services\PortalProfileAccess::class)->authorize(request()->user(), 'view', $opportunity); abort_if($opportunity->status === 'archived', 404); $record = DB::table($service->table($kind).' as r')->join('documents as d', 'd.id', '=', 'r.document_id')->where('r.id', $resourceId)->where('r.opportunity_id', $id)->whereNull('r.deleted_at')->whereNull('d.deleted_at')->first(['d.*']); abort_unless($record !== null, 404, 'Recurso indisponível ou excluído logicamente.'); abort_unless($record->status === 'active' && $record->malware_scan_status === 'clean' && hash_equals(trim($record->sha256), (string) $record->malware_scanned_sha256), 423, 'Scan pendente, inconclusivo ou inseguro. O ficheiro permanece em quarentena.'); + $support = DB::table($service->table($kind))->where('id', $resourceId)->first(); + app(\App\Services\OpportunityLibrarySupport::class)->assertAvailable($support, $record); + if (request()->filled('source_sha256')) abort_unless(hash_equals(trim($record->sha256), (string)request('source_sha256')), 409, 'O original já não corresponde à versão consultada.'); return app(VerifiedDocumentBinary::class)->respond($record); } diff --git a/app/Http/Controllers/PortalManagementController.php b/app/Http/Controllers/PortalManagementController.php index 03dd9900..a45312b8 100644 --- a/app/Http/Controllers/PortalManagementController.php +++ b/app/Http/Controllers/PortalManagementController.php @@ -53,6 +53,36 @@ class PortalManagementController extends Controller return Inertia::render('management/history', ['events' => $events]); } + private function interestAssignmentEtag(InterestRequest $interest): string + { + return hash('sha256',json_encode([$interest->id,$interest->status,$interest->assigned_to,$interest->updated_at?->format('Y-m-d H:i:s.u')],JSON_THROW_ON_ERROR)); + } + + public function interestAssignments(Request $request): Response|JsonResponse + { + abort_unless(app(\App\Services\ScopedPortalAdministration::class)->administrative($request->user()),403); + $rows=InterestRequest::whereNotIn('status',['cancelled','closed','refused','converted'])->orderBy('id')->get(['id','reference','status','assigned_to','updated_at']) + ->map(fn($row)=>['id'=>$row->id,'protocol'=>$row->reference,'status'=>$row->status,'analyst_id'=>$row->assigned_to,'version'=>0,'etag'=>$this->interestAssignmentEtag($row),'due_at'=>null]); + $data=['kind'=>'interest','requests'=>$rows,'analysts'=>$this->interestAssignees()->get(['id','name'])]; + return $request->expectsJson()?response()->json($data):Inertia::render('management/desk-assignments',$data); + } + + public function assignInterest(Request $request, InterestRequest $interest): RedirectResponse|JsonResponse + { + abort_unless(app(\App\Services\ScopedPortalAdministration::class)->administrative($request->user()),403); + $data=$request->validate(['etag'=>'required|string|size:64','analyst_id'=>['required','integer',Rule::in($this->interestAssignees()->pluck('id'))],'reason'=>'required|string|min:10|max:2000'],['reason.required'=>'Explique o motivo da atribuição.','reason.min'=>'Explique o motivo da atribuição com pelo menos 10 caracteres.','analyst_id.required'=>'Seleccione o responsável.','analyst_id.in'=>'Seleccione um responsável autorizado.']); + DB::transaction(function()use($request,$interest,$data){ + $row=InterestRequest::whereKey($interest->id)->lockForUpdate()->firstOrFail(); + if (!hash_equals($this->interestAssignmentEtag($row),$data['etag'])) throw ValidationException::withMessages(['etag'=>'A atribuição mudou. Recupere a lista actual e tente novamente.']); + if (in_array($row->status,['cancelled','closed','refused','converted'],true)) throw ValidationException::withMessages(['status'=>'Este pedido já não permite uma nova atribuição. Recupere a lista actual.']); + $owner=(int)($row->metadata['portal_user_id']??0); + abort_if($owner===(int)$data['analyst_id'] || InvestmentSubmission::where('interest_request_id',$row->id)->where('user_id',$data['analyst_id'])->exists(),422,'O interessado não pode acompanhar o seu próprio pedido como equipa.'); + PortalAudit::save($row,['assigned_to'=>(int)$data['analyst_id']],$request->user()->id); + app(\App\Services\PortalProfileAccess::class)->audit($request->user(),'interest_assigned',['interest_id'=>$row->id,'assigned_to'=>(int)$data['analyst_id'],'reason'=>$data['reason']]); + }); + return $request->expectsJson()?response()->json(['ok'=>true]):back()->with('success','Responsável atribuído.'); + } + public function interests(Request $request): Response { $filters = $request->validate([ @@ -65,6 +95,7 @@ class PortalManagementController extends Controller $query = InterestRequest::query()->with('assignee:id,name')->select([ 'id', 'reference', 'full_name', 'email', 'country', 'metadata', 'sector_interest', 'existing_business_id', 'modality', 'status', 'assigned_to', 'next_action', 'next_action_due_at', 'created_at', ]); + app(\App\Services\ScopedPortalAdministration::class)->interestQuery($query, $request->user()); if ($country = $filters['country'] ?? null) { $values = app(CountryCatalogue::class)->filterValues($country); $query->whereIn(DB::raw('LOWER(BTRIM(country))'), $values); @@ -111,6 +142,7 @@ class PortalManagementController extends Controller public function interest(InterestRequest $interest): Response { + abort_unless(app(\App\Services\ScopedPortalAdministration::class)->interest(request()->user(), $interest),403); $history = DB::table('business_workflow_transitions')->where('interest_request_id', $interest->id) ->orderByDesc('created_at')->limit(50)->get(['from_status', 'to_status', 'actor_id', 'reason', 'created_at']) ->map(function ($row) { @@ -138,12 +170,18 @@ class PortalManagementController extends Controller public function review(Request $request, InterestRequest $interest, InterestWorkflow $workflow): RedirectResponse|JsonResponse { + abort_unless(app(\App\Services\ScopedPortalAdministration::class)->interest(request()->user(), $interest),403); $validated = $request->validate([ 'status' => ['required', Rule::in(self::STATES)], 'note' => ['nullable', 'string', 'min:10', 'max:2000'], 'next_action' => ['nullable', 'required_with:next_action_due_at', 'string', 'max:180'], 'next_action_due_at' => ['nullable', 'required_with:next_action', 'date', 'after_or_equal:today'], 'assigned_to' => ['nullable', 'integer', Rule::in($this->interestAssignees()->pluck('id'))], + ], [ + 'next_action_due_at.required_with' => 'Indique o prazo da próxima acção.', + 'next_action_due_at.date' => 'Indique uma data válida para a próxima acção.', + 'next_action_due_at.after_or_equal' => 'O prazo da próxima acção deve ser hoje ou uma data futura.', + 'next_action.required_with' => 'Descreva a próxima acção correspondente ao prazo indicado.', ]); $before = $interest->status; $updated = $workflow->transition($request->user(), $interest, $validated['status'], $validated['note'] ?? null, @@ -160,6 +198,7 @@ class PortalManagementController extends Controller public function withdrawProponentSharingConsent(Request $request, InterestRequest $interest): RedirectResponse|JsonResponse { + abort_unless(app(\App\Services\ScopedPortalAdministration::class)->interest(request()->user(), $interest),403); $data = $request->validate(['reason' => ['required', 'string', 'min:10', 'max:2000']]); $this->withdrawConsent($request, $interest, $data['reason'], 'share_contact_with_proponent'); @@ -169,6 +208,7 @@ class PortalManagementController extends Controller public function withdrawContactConsent(Request $request, InterestRequest $interest): RedirectResponse|JsonResponse { + abort_unless(app(\App\Services\ScopedPortalAdministration::class)->interest(request()->user(), $interest),403); $data = $request->validate(['reason' => ['required', 'string', 'min:10', 'max:2000']]); $this->withdrawConsent($request, $interest, $data['reason'], 'respond_to_interest'); @@ -217,7 +257,9 @@ class PortalManagementController extends Controller ['triagers', 'reviewers', 'approvers_l1', 'approvers_l2', 'publishers'], ))); - return User::query()->whereNotNull('email_verified_at')->whereIn(DB::raw('lower(email)'), $emails)->orderBy('name'); + return User::query()->whereNotNull('email_verified_at')->where(function ($q) use ($emails) { + $q->whereIn(DB::raw('lower(email)'), $emails)->orWhereIn('id', DB::table('portal_superuser_pair')->select('first_user_id'))->orWhereIn('id', DB::table('portal_superuser_pair')->select('second_user_id')); + })->orderBy('name'); } public function catalog(Request $request, string $resource): Response diff --git a/app/Http/Controllers/PortalServicesController.php b/app/Http/Controllers/PortalServicesController.php index a21122ab..7aacb939 100644 --- a/app/Http/Controllers/PortalServicesController.php +++ b/app/Http/Controllers/PortalServicesController.php @@ -286,17 +286,47 @@ class PortalServicesController extends Controller return app(VerifiedDocumentBinary::class)->respond($document, true); } - public function downloadForManager(string $id): BinaryFileResponse|\Illuminate\Http\Response + public function downloadForManager(Request $request, string $id): BinaryFileResponse|\Illuminate\Http\Response { - $document = DB::table('documents')->where('id', $id)->whereNull('deleted_at')->first(); + $document = app(\App\Services\DocumentManagementAccess::class)->documents($request->user())->where('id', $id)->first(); abort_unless($document && $document->status === 'active' && $document->malware_scan_status === 'clean' && hash_equals(trim($document->sha256), trim((string) $document->malware_scanned_sha256)), 404); return app(VerifiedDocumentBinary::class)->respond($document); } - public function manage(MalwareScanner $scanner): Response + public function manage(Request $request, MalwareScanner $scanner): Response { + $access = app(\App\Services\DocumentManagementAccess::class); + if (! $access->full($request->user())) { + $documents = $access->documents($request->user())->where('status', '!=', 'deleted'); + $health = $scanner->health(); + $health['signatures_at'] = $health['signatures_at']?->format(DATE_ATOM); + + $editorial = $access->editorial($request->user()); + $resources = $editorial ? $access->editorialResources($request->user())->orderByDesc('r.updated_at') + ->get(['r.*', 'd.file_name', 'd.media_type', 'd.size_bytes', 'd.issuer', 'd.status as file_status', 'd.data_classification', 'd.version_no as file_version', 'd.rights_status as file_rights_status', 'd.malware_scan_status', 'd.malware_failure_reason']) : collect(); + $links = DB::table('portal_resource_links')->whereIn('resource_id', $resources->pluck('id'))->orderBy('id')->get()->groupBy('resource_id'); + $revisions = DB::table('portal_resource_revisions')->whereIn('resource_id', $resources->pluck('id'))->orderByDesc('revision_no')->get(['resource_id', 'revision_no', 'change_type', 'actor_user_id', 'created_at'])->groupBy('resource_id'); + $resources->each(function ($resource) use ($links, $revisions) { + $resource->links = $links->get($resource->id, collect())->values(); + $resource->revisions = $revisions->get($resource->id, collect())->values(); + }); + return Inertia::render('portal/manage', [ + 'documentAccessOnly' => ! $editorial, 'editorialScopeOnly' => $editorial, 'resources' => $resources, 'partnerships' => [], 'territories' => [], + 'requirementCatalogue' => [], 'requirementAssociationsEnabled' => false, + 'disclosureAuthorization' => null, 'incompleteCount' => $resources->filter(fn ($r) => ! $r->description || ! $r->issuer || ! $r->source_url || $r->source_confirmation === 'unconfirmed' || $r->legal_status === 'unknown' || $r->rights_status === 'unconfirmed')->count(), 'possibleDuplicates' => $resources->whereNotNull('duplicate_of_resource_id')->count(), + 'documents' => (clone $documents)->latest()->limit(250)->get([ + 'id', 'file_name', 'media_type', 'size_bytes', 'issuer', 'issued_on', 'valid_until', + 'uploaded_by', 'created_at', 'status', 'data_classification', 'version_no', 'sha256', + 'rights_status', 'malware_scan_status', 'malware_scanned_at', 'malware_failure_reason', + 'malware_engine_version', 'malware_signatures_at', 'malware_scan_attempts', + ]), + 'malwareCounts' => (clone $documents)->select('malware_scan_status', DB::raw('count(*) as total')) + ->groupBy('malware_scan_status')->pluck('total', 'malware_scan_status'), + 'malwareHealth' => $health, + ]); + } $disclosureAuthorization = DB::table('audit_events')->where('source_system', 'user-instruction') ->where('external_id', 'document-repository-disclosure-authorization-2026-10-06-v1') ->where('action', 'public_disclosure_authorized')->first(['external_id', 'occurred_at', 'after_data']); @@ -378,6 +408,7 @@ class PortalServicesController extends Controller $id = DB::transaction(function () use ($data, $request): int { $document = DB::table('documents')->where('id', $data['document_id'])->where('status', '!=', 'deleted')->whereNull('deleted_at')->lockForUpdate()->first(); abort_unless($document !== null, 422, 'Seleccione um documento existente e não eliminado.'); + abort_unless(app(\App\Services\DocumentManagementAccess::class)->documents($request->user())->where('id', $document->id)->exists(), 403); $identity = $this->identityKey($data); if ($identity) { DB::select('SELECT pg_advisory_xact_lock(hashtext(?))', ['portal-resource-identity:'.$identity]); @@ -406,9 +437,11 @@ class PortalServicesController extends Controller DB::transaction(function () use ($data, $request, $id, $expectedVersion): void { $resource = DB::table('portal_resources')->where('id', $id)->lockForUpdate()->first(); abort_unless($resource !== null, 404); + app(\App\Services\DocumentManagementAccess::class)->assertEditableResource($request->user(), $id); abort_if((int) $resource->metadata_version !== $expectedVersion, 409, 'O documento foi alterado por outra sessão. Recarregue a revisão antes de editar.'); $document = DB::table('documents')->where('id', $data['document_id'])->where('status', '!=', 'deleted')->whereNull('deleted_at')->lockForUpdate()->first(); abort_unless($document !== null, 422, 'Seleccione um documento existente e não eliminado.'); + abort_unless(app(\App\Services\DocumentManagementAccess::class)->documents($request->user())->where('id', $document->id)->exists(), 403); $identity = $this->identityKey($data); if ($identity) { DB::select('SELECT pg_advisory_xact_lock(hashtext(?))', ['portal-resource-identity:'.$identity]); @@ -437,6 +470,11 @@ class PortalServicesController extends Controller public function moderate(Request $request, string $type, int $id): RedirectResponse { + $access = app(\App\Services\DocumentManagementAccess::class); + if (! $access->full($request->user())) { + abort_unless($type === 'resources', 403); + $access->assertEditableResource($request->user(), $id); + } if ($type === 'partnerships' && Schema::hasColumn('portal_partnerships', 'partnership_type')) { return redirect('/parcerias/'.$id)->with('success', 'Reveja a proposta e registe uma decisão fundamentada na ficha da Bolsa.'); } @@ -444,6 +482,7 @@ class PortalServicesController extends Controller $table = $type === 'resources' ? 'portal_resources' : 'portal_partnerships'; DB::transaction(function () use ($request, $data, $table, $type, $id) { abort_unless(DB::table($table)->where('id', $id)->lockForUpdate()->first() !== null, 404); + if ($type === 'resources') app(\App\Services\DocumentManagementAccess::class)->assertEditableResource($request->user(), $id); if ($type === 'resources' && $data['published']) { $resource = DB::table('portal_resources as r')->join('documents as d', 'd.id', '=', 'r.document_id') ->where('r.id', $id)->first(['r.document_type', 'r.legislation', 'r.source_confirmation', 'r.legal_status', 'r.rights_status', diff --git a/app/Http/Controllers/VirtualDeskController.php b/app/Http/Controllers/VirtualDeskController.php index 01c8724b..5941e298 100644 --- a/app/Http/Controllers/VirtualDeskController.php +++ b/app/Http/Controllers/VirtualDeskController.php @@ -148,7 +148,7 @@ class VirtualDeskController extends Controller $data = ['boundContextRevision' => app(InvestorContextConsumers::class)->requestRevision($row), 'countries' => app(CountryCatalogue::class)->all(), 'toolPrefill' => $desk->requestToolPrefill($request->user(), $row), 'request' => [...(array) $row, 'payload' => $payload, 'submitted_snapshot' => $desk->decode($row->submitted_snapshot), 'editable_fields' => json_decode($row->editable_fields ?? '[]', true)], 'case' => ['id' => $case->id, 'title' => $case->title], 'caseVersion' => $case->version, 'definition' => $definition, 'territoryPaths' => $territoryPaths, 'serviceSource' => json_decode(DB::table('desk_service_versions')->where('id', $row->service_version_id)->value('source'), true), - 'owner' => $desk->owns($request->user(), $case), 'manager' => $request->user()->can('manage-portal'), 'analyst' => (int) $row->analyst_id === $request->user()->id, + 'owner' => $desk->owns($request->user(), $case), 'manager' => app(\App\Services\ScopedPortalAdministration::class)->deskDecision($request->user(),$row), 'analyst' => (int) $row->analyst_id === $request->user()->id, 'fieldsEditable' => in_array($row->status, ['draft', 'returned'], true), 'states' => config('virtual-desk.request_states'), 'transitions' => config('virtual-desk.transitions.'.$row->status, []), 'findings' => $desk->findings($payload), 'ai' => config('virtual-desk.ai'), 'documents' => $this->documents($case->id, $request->user())->filter(fn ($d) => ! $d->request_id || $d->request_id === $id)->values(), 'messages' => DB::table('desk_messages as m')->join('users as u', 'u.id', '=', 'm.actor_id')->where('request_id', $id)->orderBy('m.created_at')->get(['m.id', 'm.actor_id', 'm.body', 'm.created_at', 'u.name'])->map(fn ($m) => [...(array) $m, 'body' => Crypt::decryptString($m->body), @@ -176,14 +176,26 @@ class VirtualDeskController extends Controller return $request->expectsJson() ? response()->json(['version' => $row->version, 'status' => $row->status]) : back()->with('success', 'Decisão interna registada.'); } + public function assignments(Request $request): JsonResponse|Response + { + abort_unless(app(\App\Services\ScopedPortalAdministration::class)->administrative($request->user()),403); + // Triage projection deliberately excludes payloads, documents, investor identity and case titles. + $data=['requests'=>DB::table('desk_requests')->whereNotIn('status',['draft','cancelled','closed'])->orderBy('created_at')->get(['id','protocol','status','version','analyst_id','due_at']), + 'analysts'=>User::whereNotNull('email_verified_at')->orderBy('name')->get(['id','name'])]; + return $request->expectsJson()?response()->json($data):Inertia::render('management/desk-assignments',$data); + } + public function assign(Request $request, string $id, VirtualDesk $desk): JsonResponse|RedirectResponse { - abort_unless($request->user()->can('manage-portal'), 403); - $data = $request->validate(['version' => 'required|integer', 'analyst_id' => 'required|integer|exists:users,id', 'reason' => 'required|string|min:3|max:3000', 'due_at' => 'nullable|date']); + abort_unless(app(\App\Services\ScopedPortalAdministration::class)->administrative($request->user()), 403); + $data = $request->validate(['version' => 'required|integer', 'analyst_id' => 'required|integer|exists:users,id', 'reason' => 'required|string|min:3|max:3000', 'due_at' => 'nullable|date'], ['reason.required'=>'Explique o motivo da atribuição.','reason.min'=>'Explique o motivo da atribuição com pelo menos 3 caracteres.','analyst_id.required'=>'Seleccione o responsável.','analyst_id.exists'=>'Seleccione um responsável com conta disponível.']); DB::transaction(function () use ($request, $id, $desk, $data) { - $row = $desk->request($request->user(), $id, false, true); - $case = $desk->case($request->user(), $row->case_id); - abort_unless((int) $row->version === (int) $data['version'] && ! in_array($row->status, ['draft', 'cancelled', 'closed'], true), 409); + $row = DB::table('desk_requests')->where('id',$id)->lockForUpdate()->first(); + abort_unless($row,404); + $case = DB::table('desk_cases')->where('id',$row->case_id)->first(); + abort_unless($case,404); + if ((int)$row->version !== (int)$data['version']) throw \Illuminate\Validation\ValidationException::withMessages(['version'=>'Este pedido foi actualizado. Recupere a lista e tente novamente.']); + if (in_array($row->status,['draft','cancelled','closed'],true)) throw \Illuminate\Validation\ValidationException::withMessages(['status'=>'Este pedido já não permite uma nova atribuição. Recupere a lista actual.']); $analyst = User::where('id', $data['analyst_id'])->firstOrFail(); abort_unless($analyst->hasVerifiedEmail(), 422, 'O responsável deve ter conta verificada.'); abort_if($desk->owns($analyst, $case, false), 422, 'O proponente não pode ser o analista.'); @@ -217,13 +229,18 @@ class VirtualDeskController extends Controller return $request->expectsJson() ? response()->json(['ok' => true]) : back()->with('success', 'Mensagem privada guardada.'); } - public function receipt(Request $request, string $id, VirtualDesk $desk): StreamedResponse + public function receipt(Request $request, string $id, VirtualDesk $desk): StreamedResponse|\Illuminate\Http\Response { $row = $desk->request($request->user(), $id); abort_unless($row->protocol && $row->submitted_snapshot, 404); $snapshot = $desk->decode($row->submitted_snapshot); + $format = $request->validate(['format' => 'nullable|in:html'])['format'] ?? null; $this->auditRead($request, 'desk_requests', $id, 'receipt_downloaded'); + if ($format === 'html') { + return response()->view('desk.receipt', ['record' => $row, 'snapshot' => $snapshot, 'definition' => $desk->definition($row)], 200, ['Cache-Control' => 'private, no-store', 'X-Content-Type-Options' => 'nosniff']); + } + return response()->streamDownload(function () use ($row, $snapshot) { echo 'Balcão Virtual — recibo de submissão interna'.PHP_EOL.$row->protocol.PHP_EOL.'Data: '.$row->submitted_at.PHP_EOL.'Versão: '.$row->version.PHP_EOL; echo json_encode($snapshot, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR); diff --git a/app/Http/Middleware/EnsureDocumentManagement.php b/app/Http/Middleware/EnsureDocumentManagement.php new file mode 100644 index 00000000..26af0cb8 --- /dev/null +++ b/app/Http/Middleware/EnsureDocumentManagement.php @@ -0,0 +1,28 @@ +user(); + abort_unless($actor, 403); + $access = app(DocumentManagementAccess::class); + if ($access->full($actor)) { + return $next($request); // Preserve existing management controls. + } + $allowed = $access->limited($actor) || $access->editorial($actor); + $access->audit($actor, $allowed ? 'document_access_allowed' : 'document_access_denied', [ + 'scope' => $access->editorial($actor) ? 'approved-editorial-catalogue-and-own-files' : 'own-portal-services-files', 'method' => $request->method(), 'path' => $request->path(), + ]); + abort_unless($allowed, 403, 'O acesso documental exige autorização, email confirmado e autenticação MFA nesta sessão.'); + + return $next($request); + } +} diff --git a/app/Http/Middleware/EnsureHomologationAccess.php b/app/Http/Middleware/EnsureHomologationAccess.php new file mode 100644 index 00000000..acb9b68d --- /dev/null +++ b/app/Http/Middleware/EnsureHomologationAccess.php @@ -0,0 +1,37 @@ +server('INVESTMOZ_HOMOLOGATION_ACCESS')==='granted',403, + 'Este ambiente de homologação tem acesso reservado à Tempo.'); + $response=$next($request); + $response->headers->set('Cache-Control','private, no-store'); + $response->headers->set('X-Robots-Tag','noindex, nofollow, noarchive'); + return $response; + } + abort_unless($mode==='reviewers',503,'A reserva da homologação não está configurada.'); + // Compatibility until an independent perimeter is explicitly configured. + if ($request->is('login','logout','register','forgot-password','reset-password','reset-password/*', + 'email/verify','email/verify/*','email/verification-notification','two-factor-challenge', + 'user/confirm-password','user/confirmed-password-status','user/two-factor-*', + 'settings/security','settings/password','user/password','passkeys/*','user/passkeys/*','up')) return $next($request); + $actor=$request->user(); + if (!$actor) return $request->expectsJson() + ? response()->json(['message'=>'Entre na conta autorizada para homologação.'],401) + : redirect()->guest(route('login')); + abort_unless($actor->hasVerifiedEmail() && in_array(strtolower(trim($actor->email)),config('portal.homologation_reviewers',[]),true),403, + 'Esta homologação está reservada às contas indicadas pela Tempo.'); + $response=$next($request);$response->headers->set('Cache-Control','private, no-store');return $response; + } +} diff --git a/app/Http/Middleware/EnsureScopedEditorial.php b/app/Http/Middleware/EnsureScopedEditorial.php new file mode 100644 index 00000000..6ee5b0ae --- /dev/null +++ b/app/Http/Middleware/EnsureScopedEditorial.php @@ -0,0 +1,25 @@ +user(); + abort_unless($actor, 403); + $access = app(\App\Services\DocumentManagementAccess::class); + if ($access->full($actor)) { + return $next($request); + } + abort_unless($access->editorial($actor), 403); + $allowed = in_array($request->route()->getName(), ['editorial.edit', 'editorial.save', 'services.resource', 'services.resource.update'], true) + || ($request->route()->getName() === 'services.moderate' && $request->route('type') === 'resources'); + abort_unless($allowed, 403); + if ($request->route('id') !== null) { + $access->assertEditableResource($actor, (int) $request->route('id')); + } + return $next($request); + } +} diff --git a/app/Http/Middleware/EnsureScopedPortalAdministration.php b/app/Http/Middleware/EnsureScopedPortalAdministration.php new file mode 100644 index 00000000..a55d5175 --- /dev/null +++ b/app/Http/Middleware/EnsureScopedPortalAdministration.php @@ -0,0 +1,31 @@ +user();abort_unless($actor,403); + $scope=app(ScopedPortalAdministration::class); + $path=$request->path(); + $interests=$request->is('management/interests','management/interests/*','api/v1/investment/admin/interests/*'); + // Exact areas of P1; unrelated management, fiscal rules and private records keep their gate. + $catalogue=$request->is('management/economic-references/lot-01','management/economic-references/lot-01/*', + 'management/education','management/education/*','management/investment/references','management/investment/references/*','api/v1/investment/admin/references','api/v1/investment/admin/references/*', + 'management/investment/zones','management/investment/zones/*','api/v2/investment/admin/zones','api/v2/investment/admin/zones/*', + 'management/balcao/atribuicoes','management/balcao/atribuicoes/*','management/balcao/servicos', + 'management/balcao/fontes') || ($request->isMethod('GET') && $path==='management/balcao-install'); + $allowed=$interests ? $scope->interestOperator($actor) : ($catalogue && $scope->administrative($actor)); + abort_unless($allowed || ($actor->can('manage-portal') && !app(\App\Services\PortalProfileAccess::class)->has($actor,'auditor')),403,'Acesso reservado ao âmbito já designado.'); + $response=$next($request); + if (!$request->isMethod('GET') && $response->getStatusCode()<400) { + app(\App\Services\PortalProfileAccess::class)->audit($actor,'scoped_administration',[ + 'method'=>$request->method(),'route'=>$request->route()?->uri(),'scope'=>$interests?'assigned-interest':'catalogue-administration']); + } + return $response; + } +} diff --git a/app/Http/Middleware/HandleInertiaRequests.php b/app/Http/Middleware/HandleInertiaRequests.php index c67a4541..d9f88f49 100644 --- a/app/Http/Middleware/HandleInertiaRequests.php +++ b/app/Http/Middleware/HandleInertiaRequests.php @@ -51,8 +51,10 @@ class HandleInertiaRequests extends Middleware return [ ...parent::share($request), 'name' => config('app.name'), + 'homologationReserved' => (bool) config('portal.homologation_only', false), 'profileAccess' => app(PortalProfileAccess::class)->navigation($request->user()), 'portalOperator' => config('portal.operator_name'), + 'portalInstitutionalContactEmail' => filter_var(config('portal.institutional_contact_email'), FILTER_VALIDATE_EMAIL) ?: null, 'legacyContentEnabled' => app(LegacyContent::class)->available(), 'educationEnabled' => (bool) config('education.enabled', false), 'aiEnabled' => (bool) config('investmoz_ai.enabled', false), @@ -67,6 +69,15 @@ class HandleInertiaRequests extends Middleware 'installationHref' => '/management/opportunity-management-install', ] : null, 'flash' => ['success' => fn () => $request->session()->get('success'), 'warning' => fn () => $request->session()->get('warning')], + 'scopedAdministration' => $request->user() && app(\App\Services\ScopedPortalAdministration::class)->administrative($request->user()) ? [ + ['label'=>'Zonas e parques','href'=>'/management/investment/zones'], + ['label'=>'Referências económicas','href'=>'/management/economic-references/lot-01'], + ['label'=>'Referências e formulários','href'=>'/management/investment/references'], + ['label'=>'Educação e competências','href'=>'/management/education'], + ['label'=>'Interesses atribuídos','href'=>'/management/interests'], + ['label'=>'Atribuições de interesses','href'=>'/management/interests/atribuicoes'], + ['label'=>'Atribuições do Balcão','href'=>'/management/balcao/atribuicoes'], + ] : [], 'auth' => [ 'user' => $request->user(), 'canManagePortal' => $request->user()?->can('manage-portal') ?? false, diff --git a/app/Http/Middleware/VerifiedOrSimpleParticipation.php b/app/Http/Middleware/VerifiedOrSimpleParticipation.php new file mode 100644 index 00000000..f4f06526 --- /dev/null +++ b/app/Http/Middleware/VerifiedOrSimpleParticipation.php @@ -0,0 +1,27 @@ +user()->hasVerifiedEmail()) return $next($request); + if ($request->isMethod('GET') && $request->is('investidor/manifestacoes', 'api/v1/investment/submissions')) return $next($request); + $id = $request->route('id'); + $show = $request->isMethod('GET') && $request->is('investidor/manifestacoes/'.$id, 'api/v1/investment/submissions/'.$id); + $message = $request->isMethod('POST') && $request->is('investidor/manifestacoes/'.$id.'/messages', 'api/v1/investment/submissions/'.$id.'/messages'); + if ($id && ($show || $message)) { + $row = InvestmentSubmission::whereKey($id)->where('user_id', $request->user()->id)->firstOrFail(); + if ($row->status === 'submitted' && ($row->payload['simple_participation'] ?? false) === true) return $next($request); + } + return app(EnsureEmailIsVerified::class)->handle($request, $next); + } +} diff --git a/app/Http/Requests/StoreInterestRequestRequest.php b/app/Http/Requests/StoreInterestRequestRequest.php index 825a5d87..14a3db90 100644 --- a/app/Http/Requests/StoreInterestRequestRequest.php +++ b/app/Http/Requests/StoreInterestRequestRequest.php @@ -26,7 +26,7 @@ class StoreInterestRequestRequest extends FormRequest return [ 'fullName' => ['required', 'string', 'min:3', 'max:150'], - 'email' => ['required', 'email:rfc,dns', 'max:150'], + 'email' => ['required', 'email:rfc', 'max:150'], 'phone' => ['nullable', 'string', 'max:30'], 'investorType' => ['required', 'string', Rule::in([ 'individual', 'empresa', 'fundo', 'entidade_publica', 'outro', diff --git a/app/Policies/ExistingBusinessPolicy.php b/app/Policies/ExistingBusinessPolicy.php index 14411489..3377b178 100644 --- a/app/Policies/ExistingBusinessPolicy.php +++ b/app/Policies/ExistingBusinessPolicy.php @@ -91,7 +91,7 @@ class ExistingBusinessPolicy public function scopeReviewQueue(Builder $query, User $user): void { if (app(PortalProfileAccess::class)->available()) { - $query->where('modality', 'company_sale'); + $query->whereIn('modality',array_keys(\App\Services\ExistingBusinessProfileCategory::MAP)); $ids = (clone $query)->get()->filter(fn (ExistingBusiness $business) => $this->view($user, $business))->modelKeys(); $query->whereKey($ids); } @@ -156,9 +156,8 @@ class ExistingBusinessPolicy { $access = app(PortalProfileAccess::class); if ($access->available()) { - // Only the existing company-sale consumer is reconciled in this increment. - // Do not translate other modalities or invent an organization from free text. - if (($business !== null && $business->modality !== 'company_sale') || ! $access->mfa($user)) { + $category=$business ? \App\Services\ExistingBusinessProfileCategory::category($business) : null; + if (($business !== null && $category === null) || ! $access->mfa($user) || $access->has($user,'auditor')) { return false; } $role = $designation === 'publishers' ? 'publisher' : 'validator'; @@ -166,8 +165,10 @@ class ExistingBusinessPolicy return false; } foreach ($access->grants($user, $role) as $grant) { - if ($grant->organization_id === null - && in_array('business_sale', json_decode($grant->opportunity_types, true, 512, JSON_THROW_ON_ERROR), true)) { + $organizationMatches = $grant->organization_id === null + || ($business !== null && $grant->organization_id === $business->organization_id); + if ($organizationMatches + && ($category !== null ? in_array($category,json_decode($grant->opportunity_types,true,512,JSON_THROW_ON_ERROR),true) : array_intersect(array_values(\App\Services\ExistingBusinessProfileCategory::MAP),json_decode($grant->opportunity_types,true,512,JSON_THROW_ON_ERROR)) !== [])) { return true; } } diff --git a/app/Services/BusinessFormSchema.php b/app/Services/BusinessFormSchema.php index c3ee79e2..542b1d6a 100644 --- a/app/Services/BusinessFormSchema.php +++ b/app/Services/BusinessFormSchema.php @@ -100,8 +100,11 @@ final class BusinessFormSchema $rules['payload.amount_sought'][] = 'regex:/^\d{1,15}(\.\d{1,4})?$/'; $rules['payload.source_date'][] = 'date_format:Y-m-d'; $rules['payload.official_source_url'][] = 'url'; - $rules['payload.equity_percentage'][] = 'numeric'; - $rules['payload.equity_percentage'][] = 'between:0,100'; + // The field limit is a text length, not an 8% ceiling. + $rules['payload.equity_percentage'] = ['nullable', 'numeric', 'between:0,100', + function ($attribute, $value, $fail) { + if (strlen((string) $value) > 8) $fail('Indique a percentagem com até 8 caracteres.'); + }]; foreach (['valid_until', 'mandate_valid_until'] as $date) { $rules['payload.'.$date][] = 'date_format:Y-m-d'; } @@ -116,8 +119,12 @@ final class BusinessFormSchema /** @param array $payload */ public static function validateSubmission(array $payload, ?int $sectorId, string $modality): void { - if (($payload['opportunity_type'] ?? null) !== $modality) { - throw ValidationException::withMessages(['opportunity_type' => 'O tipo da oportunidade não corresponde à classificação guardada.']); + $expectedType = ExistingBusinessProfileCategory::formType($modality); + if ($expectedType === null) { + throw ValidationException::withMessages(['opportunity_type' => 'Esta modalidade pode ser guardada em preparação. O formulário de submissão correspondente ainda não está definido; a modalidade não será alterada automaticamente.']); + } + if (($payload['opportunity_type'] ?? null) !== $expectedType) { + throw ValidationException::withMessages(['opportunity_type' => 'O tipo de oportunidade não corresponde à modalidade guardada. Recupere o formulário; a modalidade original foi preservada.']); } $rules = []; diff --git a/app/Services/DeclaredOriginStatistics.php b/app/Services/DeclaredOriginStatistics.php index d03381bd..0afa9b69 100644 --- a/app/Services/DeclaredOriginStatistics.php +++ b/app/Services/DeclaredOriginStatistics.php @@ -12,17 +12,19 @@ final class DeclaredOriginStatistics /** @return array> */ public function forManager(User $actor, ?string $origin = null): array { - abort_unless($actor->can('manage-portal'), 403); + abort_unless(app(ScopedPortalAdministration::class)->interestOperator($actor), 403); $names = array_column(app(CountryCatalogue::class)->all(), 'name', 'code'); $groups = []; - foreach (DB::table('interest_requests')->whereNull('deleted_at')->select('metadata')->cursor() as $row) { + $interests=DB::table('interest_requests')->whereNull('deleted_at'); + app(ScopedPortalAdministration::class)->interestQuery($interests,$actor); + foreach ($interests->select('metadata')->cursor() as $row) { $metadata = json_decode($row->metadata ?? '{}', true); $code = $metadata['declared_origin_country_code'] ?? null; $key = is_string($code) && isset($names[$code]) ? $code : ''; $groups[$key] = ($groups[$key] ?? 0) + 1; } $desk = []; - if (Schema::hasTable('desk_requests')) { + if ($actor->can('manage-portal') && Schema::hasTable('desk_requests')) { foreach (DB::table('desk_requests')->whereNotIn('status', ['draft', 'cancelled'])->select('payload')->cursor() as $row) { $code = app(VirtualDesk::class)->decode($row->payload)['declared_origin_country_code'] ?? null; $key = is_string($code) && isset($names[$code]) ? $code : ''; @@ -30,7 +32,7 @@ final class DeclaredOriginStatistics } } $businesses = []; - if (Schema::hasTable('existing_businesses')) { + if ($actor->can('manage-portal') && Schema::hasTable('existing_businesses')) { foreach (app(BusinessRepository::class)->query()->whereNotIn('status', ['draft', 'cancelled'])->cursor() as $business) { $code = $business->payload['declared_origin_country_code'] ?? null; $key = is_string($code) && isset($names[$code]) ? $code : ''; diff --git a/app/Services/DocumentManagementAccess.php b/app/Services/DocumentManagementAccess.php new file mode 100644 index 00000000..f3712661 --- /dev/null +++ b/app/Services/DocumentManagementAccess.php @@ -0,0 +1,86 @@ +allows('manage-portal'); + } + + public function limited(User $actor): bool + { + // Explicit Tempo authorisation; registration itself never grants access. + return $actor->hasVerifiedEmail() + && in_array(strtolower(trim($actor->email)), config('portal.document_managers', []), true) + && app(PortalProfileAccess::class)->mfa($actor); + } + + /** Restore the approved editorial operation without a global management grant. */ + public function editorial(User $actor): bool + { + return config('portal.homologation_only', false) + && in_array(strtolower(trim($actor->email)), config('homologation_editorial.accounts', []), true) + && app(PortalProfileAccess::class)->superuser($actor) + && ! app(PortalProfileAccess::class)->has($actor, 'auditor') + && app(PortalProfileAccess::class)->mfa($actor); + } + + public function editorialResources(User $actor): Builder + { + abort_unless($this->editorial($actor), 403); + return DB::table('portal_resources as r')->join('documents as d', 'd.id', '=', 'r.document_id') + ->whereNull('d.deleted_at')->where('d.status', '!=', 'deleted') + ->where(function ($query) use ($actor) { + $query->where(function ($own) use ($actor) { + $own->where('r.created_by', $actor->id)->where('d.uploaded_by', $actor->id) + ->where('d.source_system', 'portal-services'); + }); + foreach (config('homologation_editorial.resources', []) as $item) { + $query->orWhere(function ($listed) use ($item) { + $listed->where('r.id', $item['id'])->where('d.sha256', $item['sha256']) + ->where('d.external_id', $item['external_id']); + }); + } + }); + } + + public function assertEditableResource(User $actor, int $id): void + { + abort_unless($this->full($actor) || $this->editorialResources($actor)->where('r.id', $id)->exists(), 403); + } + + public function documents(User $actor): Builder + { + $query = DB::table('documents')->whereNull('deleted_at'); + if (! $this->full($actor)) { + $editorial = $this->editorial($actor); + abort_unless($this->limited($actor) || $editorial, 403); + $query->where(function ($scope) use ($actor, $editorial) { + $scope->where(fn ($own) => $own->where('uploaded_by', $actor->id)->where('source_system', 'portal-services')); + if ($editorial) { + $scope->orWhereIn('id', $this->editorialResources($actor)->select('d.id')); + } + }); + } + + return $query; + } + + public function audit(User $actor, string $action, array $data): void + { + DB::table('audit_events')->insert([ + 'source_system' => 'document-access-v1', 'entity_type' => 'authorization', + 'actor_user_id' => $actor->id, 'action' => $action, + 'data_classification' => 'restricted', 'correlation_id' => (string) Str::uuid(), + 'after_data' => json_encode($data, JSON_THROW_ON_ERROR), + ]); + } +} diff --git a/app/Services/EconomicReferenceInstallation.php b/app/Services/EconomicReferenceInstallation.php index ae390809..929e8c80 100644 --- a/app/Services/EconomicReferenceInstallation.php +++ b/app/Services/EconomicReferenceInstallation.php @@ -47,7 +47,7 @@ class EconomicReferenceInstallation private function authorize(User $actor, bool $write = false, string $operation = ''): void { - abort_unless($actor->hasVerifiedEmail() && $actor->can('manage-portal'), 403, 'Acesso reservado à gestão autorizada.'); + abort_unless($actor->hasVerifiedEmail() && app(ScopedPortalAdministration::class)->administrative($actor), 403, 'Acesso reservado à gestão autorizada.'); if (! $write) { return; } $access = app(PortalProfileAccess::class); abort_if($access->has($actor, 'auditor'), 403, 'A restrição de Auditor permite apenas consulta.'); diff --git a/app/Services/ExistingBusinessIntake.php b/app/Services/ExistingBusinessIntake.php index 9b785ffa..947e4300 100644 --- a/app/Services/ExistingBusinessIntake.php +++ b/app/Services/ExistingBusinessIntake.php @@ -37,7 +37,7 @@ final class ExistingBusinessIntake return DB::transaction(function () use ($actor, $id, $version, $action, $note, $consent, $confirmationCode) { $business = app(BusinessRepository::class)->find($id, true); - $profileWorkflow = $business->modality === 'company_sale' && app(PortalProfileAccess::class)->available(); + $profileWorkflow = ExistingBusinessProfileCategory::category($business) !== null && app(PortalProfileAccess::class)->available(); $ability = match ($action) { OpportunityStatus::Submitted->value, OpportunityStatus::Withdrawn->value => 'owns', OpportunityStatus::Triage->value => 'triage', @@ -65,6 +65,14 @@ final class ExistingBusinessIntake OpportunityStatus::Triage->value, OpportunityStatus::InReview->value, OpportunityStatus::NeedsInformation->value], ]; abort_unless(in_array($business->status, $allowed[$action], true), 409, 'Transição indisponível neste estado.'); + // Reuse the bounded delivery contract: profile correspondence is access, not functional enablement. + $category = ExistingBusinessProfileCategory::category($business); + if ($category !== null && in_array($action, ['submitted', 'published'], true)) { + $blockers = app(OpportunityCanonicalContract::class)->deliveryBlockers(['opportunity_category' => $category]); + if ($blockers) { + throw ValidationException::withMessages(['modality' => implode(' ', array_values($blockers))]); + } + } if ($action === 'submitted') { if (($business->payload['wizard_version'] ?? null) === '2') { BusinessFormSchema::validateSubmission($business->payload, $business->sector_id, $business->modality); diff --git a/app/Services/ExistingBusinessProfileCategory.php b/app/Services/ExistingBusinessProfileCategory.php new file mode 100644 index 00000000..0e7cb8d3 --- /dev/null +++ b/app/Services/ExistingBusinessProfileCategory.php @@ -0,0 +1,45 @@ +'business_sale', 'participation_sale'=>'business_sale', + 'buyer_search'=>'business_sale', 'succession'=>'business_sale', + 'franchise'=>'franchise', 'real_estate'=>'real_estate', 'land_right'=>'land_rights', + 'investment_project'=>'investment_project', 'expansion'=>'investment_project', + 'internationalization'=>'investment_project', 'agricultural_investment'=>'investment_project', + 'business_partnership'=>'business_partnership', 'strategic_partner'=>'business_partnership', + 'joint_venture'=>'business_partnership', 'capital_increase'=>'financing', + 'financial_investor'=>'financing', 'financing_request'=>'financing', + 'concession_opportunity'=>'concession', 'supply_opportunity'=>'supply', + ]; + + // Native form types are separate from the persisted modality. No default to a project. + public const FORM_TYPES = [ + 'investment_project'=>'investment_project', 'company_sale'=>'company_sale', + 'asset_sale'=>'asset_sale', 'business_partnership'=>'business_partnership', + 'financing_request'=>'financing_request', 'concession_opportunity'=>'concession_opportunity', + 'supply_opportunity'=>'supply_opportunity', 'participation_sale'=>'company_sale', + 'asset_lease'=>'asset_sale', 'capital_increase'=>'financing_request', + 'financial_investor'=>'financing_request', 'strategic_partner'=>'business_partnership', + 'joint_venture'=>'business_partnership', 'expansion'=>'investment_project', + 'agricultural_investment'=>'investment_project', 'internationalization'=>'investment_project', + 'buyer_search'=>'company_sale', 'succession'=>'company_sale', + ]; + + public static function category(ExistingBusiness $business): ?string + { + return self::MAP[$business->modality] ?? null; + } + + public static function formType(string $modality): ?string + { + return self::FORM_TYPES[$modality] ?? null; + } +} diff --git a/app/Services/InterestWorkflow.php b/app/Services/InterestWorkflow.php index a7105a5e..0abb91f9 100644 --- a/app/Services/InterestWorkflow.php +++ b/app/Services/InterestWorkflow.php @@ -41,6 +41,7 @@ final class InterestWorkflow return DB::transaction(function () use ($actor, $interest, $status, $note, $nextAction, $dueAt, $assigneeId, $assignmentProvided, $actionProvided) { $locked = InterestRequest::query()->lockForUpdate()->findOrFail($interest->id); + abort_unless(app(ScopedPortalAdministration::class)->interest($actor,$locked),403); $from = $locked->status; $actionDate = $dueAt ? Carbon::parse($dueAt)->toDateString() : null; $followUpChanged = ($actionProvided && ($locked->next_action !== $nextAction diff --git a/app/Services/InvestmentZoneOperationalDetailsInstallation.php b/app/Services/InvestmentZoneOperationalDetailsInstallation.php index b37e85ef..ce18a6b0 100644 --- a/app/Services/InvestmentZoneOperationalDetailsInstallation.php +++ b/app/Services/InvestmentZoneOperationalDetailsInstallation.php @@ -26,7 +26,7 @@ class InvestmentZoneOperationalDetailsInstallation } private function authorize(User $actor,bool $write=false): void { - abort_unless($actor->hasVerifiedEmail() && $actor->can('manage-portal'),403,'Gestão autorizada necessária.'); + abort_unless($actor->hasVerifiedEmail() && app(ScopedPortalAdministration::class)->administrative($actor),403,'Gestão autorizada necessária.'); if($write){app(PortalProfileInstallation::class)->authorize($actor,true);} } private function evidence(array $target,string $code): array diff --git a/app/Services/InvestmentZoneProfileInstallation.php b/app/Services/InvestmentZoneProfileInstallation.php index cb26d554..26d6b920 100644 --- a/app/Services/InvestmentZoneProfileInstallation.php +++ b/app/Services/InvestmentZoneProfileInstallation.php @@ -31,7 +31,7 @@ class InvestmentZoneProfileInstallation } private function authorize(User $actor,bool $write=false): void { - abort_unless($actor->hasVerifiedEmail() && $actor->can('manage-portal'),403,'Gestão autorizada necessária.'); + abort_unless($actor->hasVerifiedEmail() && app(ScopedPortalAdministration::class)->administrative($actor),403,'Gestão autorizada necessária.'); if($write){app(PortalProfileInstallation::class)->authorize($actor,true);} } private function evidence(array $target,string $code): array diff --git a/app/Services/InvestorContext.php b/app/Services/InvestorContext.php index a9e10290..059a5e5e 100644 --- a/app/Services/InvestorContext.php +++ b/app/Services/InvestorContext.php @@ -50,13 +50,24 @@ final class InvestorContext $href = ''; $title = ''; $version = null; + $documents = []; + $visibility = 'public'; + $state = null; if ($kind === 'opportunity') { abort_unless(ctype_digit($id), 422, 'Identificador de oportunidade inválido.'); - $o = Opportunity::published()->with('sector')->findOrFail((int) $id); - $title = $o->title; - $href = '/oportunidades/'.$o->slug; + $o = Opportunity::with('sector')->findOrFail((int) $id); + $published = Opportunity::published()->whereKey($o->id)->exists(); + if (!$published) { + abort_unless(request()->user() && $o->status !== 'archived' && app(PortalProfileAccess::class)->canView(request()->user(), $o), 404, 'Ficha privada indisponível neste âmbito.'); + $visibility = 'private'; + $state = app(PortalProfileAccess::class)->state($o); + } + if (request()->user()) $documents = app(OpportunityLibrarySupport::class)->snapshot(request()->user(), $o); + $draft = !$published ? app(OpportunityManagement::class)->values($o) : []; + $title = $draft['canonical']['official_title'] ?? $o->title; + $href = $published ? '/oportunidades/'.$o->slug : '/minhas-oportunidades/'.$o->id.'/view'; $version = $o->management_version; - $fields = ['project_title' => $o->title, 'project_summary' => $o->summary]; + $fields = ['project_title' => $title, 'project_summary' => $draft['canonical']['executive_summary'] ?? $o->summary]; if ($o->sector) { $fields['sector_id'] = (string) $o->sector_id; $fields['sector'] = $o->sector->name; @@ -123,6 +134,7 @@ final class InvestorContext } $fields = array_filter($fields, fn ($v) => is_scalar($v) && trim((string) $v) !== ''); $record = ['key' => $key, 'kind' => $kind, 'id' => $id, 'title' => $title, 'version' => $version, 'href' => $href, 'fields' => $fields]; + if ($documents || $visibility === 'private') $record += ['documents' => $documents, 'visibility' => $visibility, 'state' => $state]; return [...$record, 'sha256' => hash('sha256', json_encode($record, JSON_THROW_ON_ERROR))]; } @@ -156,6 +168,10 @@ final class InvestorContext foreach (Opportunity::published()->when($query, fn ($q) => $q->where('title', 'ilike', '%'.$query.'%'))->orderBy('title')->limit(51)->get(['id', 'title']) as $o) { $items[] = ['key' => 'opportunity:'.$o->id, 'title' => $o->title, 'kind' => 'opportunity']; } + if (request()->user()) { + $private = app(PortalProfileAccess::class)->scope(Opportunity::query(), request()->user())->where('status', '!=', 'published')->where('status', '!=', 'archived')->when($query, fn ($q) => $q->where('title', 'ilike', '%'.$query.'%'))->orderBy('title')->limit(51)->get(); + foreach ($private as $o) if (app(PortalProfileAccess::class)->canView(request()->user(), $o)) $items[] = ['key' => 'opportunity:'.$o->id, 'title' => $o->title.' — preparação privada', 'kind' => 'opportunity']; + } if (config('existing-business.enabled')) { foreach (ExistingBusiness::published()->when($query, fn ($q) => $q->where('public_title', 'ilike', '%'.$query.'%'))->orderBy('public_title')->limit(51)->get(['id', 'public_title']) as $o) { $items[] = ['key' => 'business:'.$o->id, 'title' => $o->public_title, 'kind' => 'business']; diff --git a/app/Services/LegacyContent.php b/app/Services/LegacyContent.php index 37ec66c3..0d50542e 100644 --- a/app/Services/LegacyContent.php +++ b/app/Services/LegacyContent.php @@ -40,7 +40,7 @@ final class LegacyContent abort_unless($this->available(), 404); $identity = config('legacy_content.articles.'.$slug); abort_unless(is_array($identity) && ($identity['publishable'] ?? false), 404); - $row = $this->publicResources()->where('d.external_id', $identity['external_id'])->first(); + $row = $this->identityResources()->get($identity['external_id']); abort_unless($row !== null, 404); $binary = app(VerifiedDocumentBinary::class)->respond($row); $bytes = $binary instanceof BinaryFileResponse ? file_get_contents($binary->getFile()->getPathname()) : $binary->getContent(); @@ -106,7 +106,7 @@ final class LegacyContent public function articleForResource(int $resourceId): array { abort_unless($this->available(), 404); - $externalId = $this->publicResources()->where('r.id', $resourceId)->first()?->external_id; + $externalId = $this->identityResources()->firstWhere('resource_id', $resourceId)?->legacy_identity; foreach (config('legacy_content.articles', []) as $slug => $identity) { if ($identity['external_id'] === $externalId && $identity['publishable']) { return $this->article($slug); @@ -132,7 +132,7 @@ final class LegacyContent private function related(string $group, ?string $except = null): array { $identities = array_filter(config('legacy_content.articles', []), fn ($identity, $slug) => $identity['group'] === $group && $identity['publishable'] && $slug !== $except, ARRAY_FILTER_USE_BOTH); - $resources = $this->publicResources()->whereIn('d.external_id', array_column($identities, 'external_id'))->get()->keyBy('external_id'); + $resources = $this->identityResources(); $result = []; foreach ($identities as $slug => $identity) { $row = $resources->get($identity['external_id']); @@ -146,8 +146,7 @@ final class LegacyContent private function localLinks(string $html): string { - $public = $this->publicResources()->whereIn('d.external_id', array_column(config('legacy_content.articles', []), 'external_id')) - ->get()->keyBy('external_id'); + $public = $this->identityResources(); $links = []; foreach (config('legacy_content.articles', []) as $slug => $identity) { if ($identity['publishable'] && $public->get($identity['external_id'])?->source_url === $identity['source_url']) { @@ -208,6 +207,39 @@ final class LegacyContent }); } + /** Resolve explicit transport aliases without changing original IDs, versions or relations. */ + private function identityResources(): \Illuminate\Support\Collection + { + $identities = array_column(config('legacy_content.articles', []), 'external_id'); + $aliases = config('legacy_content_aliases', []); + $rows = $this->publicResources()->where(function ($query) use ($identities, $aliases) { + $query->whereIn('d.external_id', $identities); + if ($aliases !== []) { + $query->orWhereIn('r.id', array_column($aliases, 'resource_id')); + } + })->get(); + $resolved = collect(); + foreach ($rows as $row) { + $identity = in_array($row->external_id, $identities, true) ? $row->external_id : null; + foreach ($aliases as $original => $alias) { + if ((int) $row->resource_id === (int) $alias['resource_id'] + && $row->external_id === $alias['external_id'] + && hash_equals($alias['sha256'], trim($row->sha256)) + && $row->source_url === $alias['source_url']) { + abort_if($identity !== null && $identity !== $original, 409, 'Correspondência de artigo em reconciliação.'); + $identity = $original; + } + } + if ($identity === null) { + continue; + } + abort_if($resolved->has($identity), 409, 'Correspondência de artigo em reconciliação.'); + $row->legacy_identity = $identity; + $resolved->put($identity, $row); + } + return $resolved; + } + private function publicResources(): Builder { return DB::table('portal_resources as r')->join('documents as d', 'd.id', '=', 'r.document_id') diff --git a/app/Services/Malware/ClamDScanner.php b/app/Services/Malware/ClamDScanner.php index c20d66c7..bf946614 100644 --- a/app/Services/Malware/ClamDScanner.php +++ b/app/Services/Malware/ClamDScanner.php @@ -169,14 +169,23 @@ final class ClamDScanner implements MalwareScanner throw new RuntimeException('No production scanner is configured.'); } - $host = (string) config('services.malware.host', '127.0.0.1'); - $port = (int) config('services.malware.port', 3310); - if ($host === '' || in_array($host, ['0.0.0.0', '::'], true) || $port < 1 || $port > 65535) { - throw new RuntimeException('The ClamD endpoint is invalid.'); + $unix = config('services.malware.socket'); + if ($unix !== null && $unix !== '') { + if (! is_string($unix) || ! str_starts_with($unix, '/') || str_contains($unix, "\0") || strlen($unix) > 107) { + throw new RuntimeException('The ClamD Unix socket is invalid.'); + } + $endpoint = 'unix://'.$unix; + } else { + $host = (string) config('services.malware.host', '127.0.0.1'); + $port = (int) config('services.malware.port', 3310); + if ($host === '' || in_array($host, ['0.0.0.0', '::'], true) || $port < 1 || $port > 65535) { + throw new RuntimeException('The ClamD endpoint is invalid.'); + } + $endpoint = "tcp://{$host}:{$port}"; } $timeout = max(1, (int) config('services.malware.timeout_seconds', 120)); - $socket = @stream_socket_client("tcp://{$host}:{$port}", $errorCode, $errorMessage, min(10, $timeout)); + $socket = @stream_socket_client($endpoint, $errorCode, $errorMessage, min(10, $timeout)); if (! is_resource($socket)) { throw new RuntimeException('ClamD is unavailable.'); } diff --git a/app/Services/OpportunityCanonicalContract.php b/app/Services/OpportunityCanonicalContract.php index 2e7dd646..7b0d4674 100644 --- a/app/Services/OpportunityCanonicalContract.php +++ b/app/Services/OpportunityCanonicalContract.php @@ -45,7 +45,7 @@ class OpportunityCanonicalContract public const VERSION = 'opportunity-canonical-adapter-20261010-semantic1'; /** @return array */ - public function schema(bool $draft = false): array + public function schema(bool $draft = false, array $payload = []): array { $bytes = file_get_contents(resource_path('data/investmoz-opportunity-modular.schema.json')); if ($bytes === false) { @@ -59,11 +59,14 @@ class OpportunityCanonicalContract $schema = app(OpportunityCategoryFields::class)->enrich($schema); $schema = app(OpportunityModularProfiles::class)->extend($schema); $schema = app(OpportunityClassification::class)->extend($schema); + $schema = app(OpportunityInitiativeApplicability::class)->schema($schema, $payload); $schema = $this->writeSchema($schema, $draft); $properties = &$schema['$defs']['opportunityCreate']['properties']; unset($properties['mutation_context']); // Actor, audit reason and revision are server-owned. $properties['sector_id'] = ['type' => 'integer', 'minimum' => 1]; - $properties['promoter_id'] = ['type' => ['string', 'integer']]; + if (! app(OpportunityInitiativeApplicability::class)->enabled($payload)) { + $properties['promoter_id'] = ['type' => ['string', 'integer']]; + } // References point to the existing collections; their metadata is recovered on the server. foreach (['locations', 'documents', 'images'] as $field) { $properties[$field]['items'] = ['type' => 'string', 'format' => 'uuid']; @@ -144,7 +147,7 @@ class OpportunityCanonicalContract /** @param array $payload */ private function validateSchema(array $payload, bool $draft): void { - $schema = $this->schema($draft); + $schema = $this->schema($draft, $payload); $validator = new CompliantValidator(null, 100, false); $data = $this->schemaData($payload, $schema['$defs']['opportunityCreate'], $schema['$defs']); $result = $validator->validate($data, json_decode(json_encode($schema, JSON_THROW_ON_ERROR))); @@ -168,6 +171,11 @@ class OpportunityCanonicalContract } $prefix = rtrim('canonical.'.implode('.', $node->data()->fullPath()), '.'); $args = $node->args(); + if ($node->keyword() === 'type' && in_array($prefix, ['canonical.initiative.responsible_name', 'canonical.initiative.coverage_statement'], true)) { + $errors[$prefix][] = 'Informação em falta: preencha este campo antes de submeter.'; + return; + } + if ($node->keyword() === 'required') { foreach ($args['missing'] ?? [] as $field) { $errors[$prefix.'.'.$field][] = 'Preencha este campo antes de submeter.'; @@ -241,15 +249,21 @@ class OpportunityCanonicalContract { $values = app(OpportunityManagement::class)->values($record); $payload = $values['canonical'] ?? []; - $this->validateSchema($payload, false); $errors = []; + $initiative = app(OpportunityInitiativeApplicability::class); + $initiative->validate($payload, $errors); + try { + $this->validateSchema($payload, false); + } catch (ValidationException $failure) { + throw ValidationException::withMessages([...$failure->errors(), ...$errors]); + } $check = function (bool $valid, string $path, string $message) use (&$errors): void { if (! $valid) { $errors['canonical.'.$path] = $message; } }; $check(($payload['opportunity_category'] ?? null) === $record->opportunity_category, 'opportunity_category', 'A categoria deve coincidir com o registo e a autorização.'); - $check(($payload['promoter_id'] ?? null) === ($record->organization_id ?? (int) $record->owner_user_id), 'promoter_id', 'Identifique o promotor real da oportunidade; o operador do portal não o substitui.'); + $check($initiative->enabled($payload) || ($payload['promoter_id'] ?? null) === ($record->organization_id ?? (int) $record->owner_user_id), 'promoter_id', 'Identifique o promotor real da oportunidade; o operador do portal não o substitui.'); $previous = app(OpportunityManagement::class)->values($record)['canonical'] ?? []; $sameTaxonomy = ($previous['sector_id'] ?? null) === ($payload['sector_id'] ?? null) && ($previous['subsector_id'] ?? null) === ($payload['subsector_id'] ?? null) @@ -284,6 +298,7 @@ class OpportunityCanonicalContract && hash_equals(trim($file->sha256), trim((string) $file->malware_scanned_sha256)), "$kind.$index", 'Original ativo com análise antimalware correspondente obrigatória.'); if ($file) { // Verify actual bytes/cipher integrity as well as the recorded scan hash. + app(\App\Services\OpportunityLibrarySupport::class)->assertAvailable($row, $file); app(VerifiedDocumentBinary::class)->respond($file); $row->sha256 = trim($file->sha256); } @@ -296,7 +311,7 @@ class OpportunityCanonicalContract $check(! $hasArea || $hasUnit, "locations.$index.area_unit", 'Indique a unidade da área antes de submeter; a preparação incompleta continua guardada nos recursos.'); $check(! $hasUnit || $hasArea, "locations.$index.area_value", 'Indique o valor da área antes de submeter; a unidade não determina o valor.'); $check(! $hasUnit || in_array($location['area_unit'], OpportunityResources::AREA_UNITS, true), "locations.$index.area_unit", 'Use uma unidade do catálogo existente: sqm, ha ou sqkm.'); - $check(! empty($location['district_id']) && ! empty($location['role']) && array_key_exists('is_primary', $location), "locations.$index", 'Localização requer distrito, papel e indicação de principal.'); + $check(! $initiative->applicable($payload, 'physical_site') || (! empty($location['district_id']) && ! empty($location['role']) && array_key_exists('is_primary', $location)), "locations.$index", 'Localização requer distrito, papel e indicação de principal.'); app(InvestmentGeography::class)->selection($location, true); $country = $location['country_code'] ?? null; $check($country === 'MZ', "locations.$index.country_code", 'A hierarquia administrativa moçambicana deve declarar MZ; outros países exigem localização própria.'); @@ -306,8 +321,8 @@ class OpportunityCanonicalContract } $locations = $resources['locations'] ?? []; $primary = array_filter($locations, fn ($l) => $l['payload']['is_primary'] ?? false); - $check(count($primary) === 1, 'locations', 'Selecione exatamente uma localização principal.'); - $check($payload['location_mode'] !== 'single' || count($locations) === 1, 'location_mode', 'Modo single exige uma localização.'); + $check(! $initiative->applicable($payload, 'physical_site') || count($primary) === 1, 'locations', 'Selecione exatamente uma localização principal.'); + $check(! $initiative->applicable($payload, 'physical_site') || ($payload['location_mode'] ?? null) !== 'single' || count($locations) === 1, 'location_mode', 'Modo single exige uma localização.'); foreach ($payload['evidences'] as $i => $evidence) { $check(isset($resources['documents'][$evidence['document_id']]), "evidences.$i.document_id", 'Evidência deve usar um documento ativo desta oportunidade incluído no contrato.'); } @@ -356,7 +371,7 @@ class OpportunityCanonicalContract $errors['canonical.'.$module] = 'Módulo incompatível com a categoria. Preserve-o noutra versão antes de mudar de categoria.'; } } - if ($category === 'investment_project') { + if ($category === 'investment_project' && app(OpportunityInitiativeApplicability::class)->applicable($p, 'unit_investment')) { $total = $p['total_investment']; $sum = array_sum(array_column($p['investment_breakdown'], 'amount')); $secured = array_sum(array_column($p['secured_financing'], 'amount')); diff --git a/app/Services/OpportunityCategoryFields.php b/app/Services/OpportunityCategoryFields.php index 57b786b1..2fc3f2e2 100644 --- a/app/Services/OpportunityCategoryFields.php +++ b/app/Services/OpportunityCategoryFields.php @@ -65,7 +65,12 @@ class OpportunityCategoryFields // The extracted Create property was opaque, preventing completion through the UI. $common['environmental_social'] = ['$ref' => '#/$defs/environmentalSocialCreate']; $common['maturity_stage']['enum'] = [...array_keys(OpportunityMaturity::STAGES), null]; - $common['financial_indicators'] = $this->object(['npv' => ['type' => 'number'], 'irr' => ['type' => 'number'], 'payback_years' => $n, 'scenario' => $s, 'horizon_years' => ['type' => 'integer', 'minimum' => 1], 'currency' => ['type' => 'string', 'pattern' => '^[A-Z]{3}$'], 'discount_rate' => $n, 'model_document_id' => $d], ['npv', 'irr', 'payback_years', 'scenario', 'horizon_years', 'currency', 'discount_rate', 'model_document_id']); + $common['financial_indicators'] = $this->object(['npv' => ['type' => 'number'], 'irr' => ['type' => 'number'], 'payback_years' => $n, 'scenario' => $s, 'horizon_years' => ['type' => 'integer', 'minimum' => 1], 'currency' => ['type' => 'string', 'pattern' => '^[A-Z]{3}$'], 'discount_rate' => $n, 'model_document_id' => $d]); + // OP-9f173ed3-0043: a historical scenario is not a completed feasibility model. + $defs['opportunityCreate']['allOf'][] = [ + 'if' => ['properties' => ['maturity_stage' => ['enum' => ['feasibility', 'structured', 'investment_ready', 'implementation', 'operational']]], 'required' => ['maturity_stage']], + 'then' => ['required' => ['financial_indicators'], 'properties' => ['financial_indicators' => ['required' => ['npv', 'irr', 'payback_years', 'scenario', 'horizon_years', 'currency', 'discount_rate', 'model_document_id']]]], + ]; $defs['milestoneCreate']['properties'] += ['start_date' => $date, 'end_date' => $date, 'responsible_name' => $s, 'dependencies' => $strings]; $defs['milestoneCreate']['required'] = array_values(array_unique([...$defs['milestoneCreate']['required'], 'start_date', 'end_date', 'responsible_name'])); $defs['riskCreate']['properties']['mitigation']['minLength'] = 1; @@ -166,7 +171,7 @@ class OpportunityCategoryFields foreach ($p['risks'] as $i => $risk) { $fail(($risk['likelihood'] < 4 && $risk['severity'] < 4) || ! empty($risk['mitigation']), "risks.$i.mitigation", 'Risco com probabilidade ou gravidade alta exige mitigação.'); } - if (in_array($p['maturity_stage'], ['feasibility', 'structured', 'investment_ready', 'implementation', 'operational'], true)) { + if (app(OpportunityInitiativeApplicability::class)->applicable($p, 'unit_investment') && in_array($p['maturity_stage'], ['feasibility', 'structured', 'investment_ready', 'implementation', 'operational'], true)) { $fail(! empty($p['financial_indicators']), 'financial_indicators', 'A partir de viabilidade, indique cenário, indicadores e modelo financeiro.'); $fail(count(array_filter($p['evidences'], fn ($e) => in_array($e['evidence_type'], ['feasibility_study', 'feasibility'], true))) > 0, 'evidences', 'Associe o estudo de viabilidade desta etapa.'); } diff --git a/app/Services/OpportunityCompleteness.php b/app/Services/OpportunityCompleteness.php index 8b5f8d38..3bbcd841 100644 --- a/app/Services/OpportunityCompleteness.php +++ b/app/Services/OpportunityCompleteness.php @@ -11,7 +11,7 @@ class OpportunityCompleteness * @return array */ public function calculate(array $payload): array { - $schema = app(OpportunityCanonicalContract::class)->schema(); + $schema = app(OpportunityCanonicalContract::class)->schema(false, $payload); $validator = new Validator; $paths = []; $walk = function (mixed $value, array $node, string $path) use (&$walk, &$paths, $schema, $validator): void { diff --git a/app/Services/OpportunityInitiativeApplicability.php b/app/Services/OpportunityInitiativeApplicability.php new file mode 100644 index 00000000..bf6f015f --- /dev/null +++ b/app/Services/OpportunityInitiativeApplicability.php @@ -0,0 +1,94 @@ + ['location_mode', 'licenses', 'environmental_social'], + 'individual_business' => ['promoter_id', 'promoter_type', 'opportunity_type', 'investor_profiles', 'products', 'assets', 'inputs', 'markets', 'competitive_advantage', 'employment', 'impact_indicators', 'enterprise_size'], + 'unit_investment' => ['total_investment', 'currency', 'investment_breakdown', 'secured_financing', 'funding_sought', 'financial_indicators'], + ]; + + public function enabled(array $p): bool + { + return ($p['opportunity_category'] ?? null) === 'investment_project' + && in_array($p['initiative']['kind'] ?? null, ['program', 'portfolio'], true); + } + + public function applicable(array $p, string $group): bool + { + return ! $this->enabled($p) || ($p['initiative']['applicability'][$group] ?? 'missing') !== 'not_applicable'; + } + + public function schema(array $schema, array $payload): array + { + $base = &$schema['$defs']['opportunityCreate']; + $text = ['type' => ['string', 'null'], 'maxLength' => 4000]; + $base['properties']['initiative'] = ['type' => 'object', 'additionalProperties' => false, + 'required' => ['kind', 'responsible_name', 'coverage_statement', 'applicability'], + 'properties' => [ + 'kind' => ['enum' => ['program', 'portfolio']], + 'responsible_name' => ['type' => 'string', 'minLength' => 1, 'maxLength' => 500], + 'coverage_statement' => ['type' => 'string', 'minLength' => 1, 'maxLength' => 4000], + 'aggregate_budget_statement' => $text, + 'applicability' => ['type' => 'object', 'additionalProperties' => false, + 'required' => array_keys(self::GROUPS), + 'properties' => array_fill_keys(array_keys(self::GROUPS), ['enum' => ['applicable', 'not_applicable', 'missing']])], + ]]; + if (! $this->enabled($payload)) { + // A profile cannot relax requirements of another category. + if (isset($payload['initiative'])) { + $base['allOf'][] = ['properties' => ['opportunity_category' => ['const' => 'investment_project']]]; + } + return $schema; + } + $ignored = []; + foreach (self::GROUPS as $group => $fields) { + if (! $this->applicable($payload, $group)) { + $ignored = [...$ignored, ...$fields]; + } + } + // Program responsibility is distinct from component promoter identity, in all cases. + $ignored = array_unique([...$ignored, 'promoter_id', 'promoter_type']); + $base['required'] = array_values(array_diff($base['required'], $ignored)); + $base['required'][] = 'initiative'; + foreach ($ignored as $field) { + $base['properties'][$field] = ['description' => 'Not applicable; retained without alteration']; // Retain old bytes; do not validate an inapplicable placeholder. + } + foreach ($base['allOf'] as &$condition) { + if (isset($condition['then']['required'])) { + $condition['then']['required'] = array_values(array_diff($condition['then']['required'], $ignored)); + } + if (isset($condition['then']['properties'])) { + $condition['then']['properties'] = array_diff_key($condition['then']['properties'], array_flip($ignored)); + } + // Location counts apply to implantation, not to the coverage resources. + if (! $this->applicable($payload, 'physical_site') && isset($condition['if']['properties']['location_mode'])) { + $condition = ['description' => 'Coverage is distinct from physical implantation']; + } + } + unset($condition); + if (! $this->applicable($payload, 'physical_site')) { + unset($base['properties']['locations']['minItems']); + } + return $schema; + } + + public function validate(array $p, array &$errors): void + { + if (! $this->enabled($p)) { + return; + } + if (! in_array($p['maturity_stage'] ?? null, array_keys(OpportunityMaturity::STAGES), true)) { + $errors['canonical.maturity_stage'] = 'Indique a etapa conhecida do programa/carteira; a classificação não confirma preparação ou viabilidade.'; + } + foreach (self::GROUPS as $group => $fields) { + if (($p['initiative']['applicability'][$group] ?? 'missing') === 'missing') { + $errors['canonical.initiative.applicability.'.$group] = 'Informação em falta: indique se este grupo é aplicável ou não aplicável à ficha; não invente dados das componentes.'; + } + } + } +} + diff --git a/app/Services/OpportunityLibrarySupport.php b/app/Services/OpportunityLibrarySupport.php new file mode 100644 index 00000000..12a0c00d --- /dev/null +++ b/app/Services/OpportunityLibrarySupport.php @@ -0,0 +1,100 @@ +sources($actor)->orderBy('r.id')->limit(100)->get(['r.id', 'r.title', 'r.version_label', 'r.metadata_version', 'r.published', 'd.sha256'])->all(); + } + + private function sources(User $actor) + { + return DB::table('portal_resources as r')->join('documents as d', 'd.id', '=', 'r.document_id') + ->whereNull('d.deleted_at')->where('d.status', '!=', 'deleted') + ->where('r.rights_status', '!=', 'restricted')->where('d.rights_status', '!=', 'restricted') + ->whereIn('d.data_classification', ['internal', 'public']) + ->when(! $actor->can('manage-portal'), function ($query) use ($actor) { + $query->where(function ($scope) use ($actor) { + $scope->where(fn ($public) => $public->where('r.published',true)->where('d.data_classification','public')->where('d.status','active')->where('d.malware_scan_status','clean')->whereColumn('d.sha256','d.malware_scanned_sha256')); + if ($actor->hasVerifiedEmail()) { + $scope->orWhere(fn ($own) => $own->where('r.created_by',$actor->id)->where('d.uploaded_by',$actor->id)); + } + $editorial=app(DocumentManagementAccess::class); + if ($editorial->editorial($actor)) { + $scope->orWhereIn('r.id',$editorial->editorialResources($actor)->select('r.id')); + } + }); + }); + } + + public function attach(User $actor, Opportunity $opportunity, array $input): \stdClass + { + app(PortalProfileAccess::class)->authorize($actor, 'edit', $opportunity); + $data = Validator::make($input, ['resource_id' => 'required|integer|min:1', 'creation_key' => 'required|uuid', 'etag' => 'required|string|size:64', 'reason' => 'required|string|min:20|max:1000', 'section' => 'nullable|string|max:255', 'supported_statement' => 'nullable|string|max:2000'])->validate(); + + return DB::transaction(function () use ($actor, $opportunity, $data) { + $opportunity = Opportunity::whereKey($opportunity->id)->lockForUpdate()->firstOrFail(); + app(PortalProfileAccess::class)->authorize($actor, 'edit', $opportunity); + $source = $this->sources($actor)->where('r.id', $data['resource_id'])->lockForUpdate()->first(['r.*', 'd.id as original_id', 'd.sha256', 'd.version_no']); + abort_unless($source, 403, 'O acesso actual à biblioteca não permite reutilizar este original.'); + $existing = DB::table('opportunity_documents')->where('opportunity_id', $opportunity->id)->whereNull('deleted_at')->where('document_id', $source->original_id)->first(); + if ($existing) { + $payload = json_decode($existing->payload, true); + abort_unless(($payload['library_source']['resource_id'] ?? null) === $source->id, 409, 'Original já associado por outro percurso; recuperar o suporte existente.'); + abort_unless(($payload['section'] ?? null) === ($data['section'] ?? null) && ($payload['supported_statement'] ?? null) === ($data['supported_statement'] ?? null), 409, 'Original já associado com outra referência; editar explicitamente o suporte existente.'); + return $existing; + } + abort_unless(hash_equals(app(OpportunityManagement::class)->etag($opportunity), $data['etag']), 409, 'A ficha mudou; preserve a selecção e recupere a versão actual.'); + abort_if(DB::table('opportunity_documents')->where('id', $data['creation_key'])->exists(), 409, 'Chave já utilizada noutro suporte.'); + abort_if(DB::table('opportunity_documents')->where('opportunity_id', $opportunity->id)->whereNull('deleted_at')->count() >= 100, 422, 'Limite de documentos atingido.'); + $payload = ['title' => $source->title, 'category' => $source->category, 'issuer' => null, 'edition' => $source->version_label, + 'classification' => 'internal', 'visibility' => 'internal', 'source_reference' => $source->source_reference, + 'section' => $data['section'] ?? null, 'supported_statement' => $data['supported_statement'] ?? null, + 'library_source' => ['resource_id' => $source->id, 'document_id' => $source->original_id, 'metadata_version' => $source->metadata_version, + 'document_version' => $source->version_no, 'edition' => $source->version_label, 'sha256' => trim($source->sha256), 'source_reference' => $source->source_reference]]; + $now = now(); + DB::table('opportunity_documents')->insert(['id' => $data['creation_key'], 'opportunity_id' => $opportunity->id, 'document_id' => $source->original_id, + 'payload' => json_encode($payload, JSON_THROW_ON_ERROR), 'version' => 1, 'created_by' => $actor->id, 'created_at' => $now, 'updated_at' => $now]); + $after = DB::table('opportunity_documents')->where('id', $data['creation_key'])->first(); + DB::table('audit_events')->insert(['source_system' => 'opportunity-resources', 'entity_type' => 'documents', 'actor_user_id' => $actor->id, + 'action' => 'library_original_reused', 'data_classification' => 'restricted', 'before_data' => json_encode(['snapshot' => Crypt::encryptString('null')]), + 'after_data' => json_encode(['opportunity_id' => $opportunity->id, 'resource_id' => $after->id, 'version' => 1, + 'reason' => Crypt::encryptString($data['reason']), 'snapshot' => Crypt::encryptString(json_encode($after, JSON_THROW_ON_ERROR))])]); + $opportunity->management_version++; + $opportunity->save(); + return $after; + }); + } + + public function assertAvailable(\stdClass $resource, \stdClass $document): void + { + $payload = is_string($resource->payload) ? json_decode($resource->payload, true) : $resource->payload; + $pin = $payload['library_source'] ?? null; + if (! $pin) return; + $source = DB::table('portal_resources')->where('id', $pin['resource_id'])->first(); + abort_unless($source && $source->rights_status !== 'restricted' && $document->rights_status !== 'restricted' + && in_array($document->data_classification, ['internal', 'public'], true) + && $document->id === $pin['document_id'] && hash_equals($pin['sha256'], trim($document->sha256)), 403, 'O original está retirado, restrito ou já não corresponde à versão guardada.'); + } + + public function snapshot(User $actor, Opportunity $opportunity): array + { + $access = app(PortalProfileAccess::class); + if (! $access->canView($actor, $opportunity)) return []; + return collect(app(OpportunityResources::class)->listing($opportunity->id, 'documents'))->filter(fn ($r) => ! $r->deleted_at && isset($r->payload['library_source']))->map(function ($r) use ($opportunity) { + $pin = $r->payload['library_source']; + return [...$pin, 'title' => $r->payload['title'], 'support_id' => $r->id, 'support_version' => $r->version, + 'section' => $r->payload['section'] ?? null, 'opportunity_id' => $opportunity->id, + 'download_url' => '/minhas-oportunidades/'.$opportunity->id.'/resources/documents/'.$r->id.'/download?source_sha256='.$pin['sha256']]; + })->values()->all(); + } +} diff --git a/app/Services/OpportunityResources.php b/app/Services/OpportunityResources.php index 47055677..a0bcd5cf 100644 --- a/app/Services/OpportunityResources.php +++ b/app/Services/OpportunityResources.php @@ -62,6 +62,11 @@ class OpportunityResources 'payload' => 'present|array', ])->validate(); $payload = $this->validatePayload($kind, $data['payload'], $opportunityId); + if ($kind === 'documents' && $id && $file) { + $linked = DB::table($table)->where('id', $id)->where('opportunity_id', $opportunityId)->first(); + abort_if($linked && isset(json_decode($linked->payload, true)['library_source']), 422, + 'Este suporte reutiliza um original da biblioteca; seleccionar outra versão pela biblioteca.'); + } $stored = null; $documentId = null; if ($file) { @@ -114,6 +119,10 @@ class OpportunityResources abort_unless(Storage::disk('local')->put($stored, Crypt::encryptString($original)) === true, 500, 'Falha ao cifrar original.'); DB::table('documents')->insert(['id' => $documentId, 'source_system' => 'opportunity-resources', 'file_name' => basename($file->getClientOriginalName()), 'media_type' => $file->getMimeType(), 'storage_key' => $stored, 'storage_encryption' => 'laravel', 'sha256' => $hash, 'status' => 'quarantined', 'data_classification' => 'restricted', 'size_bytes' => $file->getSize(), 'uploaded_by' => $actor->id]); } + if ($record && $kind === 'documents' && isset(json_decode($record->payload, true)['library_source'])) { + abort_if($file !== null, 422, 'Este suporte reutiliza um original da biblioteca; seleccionar outra versão pela biblioteca.'); + $payload['library_source'] = json_decode($record->payload, true)['library_source']; + } $values = ['payload' => json_encode($payload, JSON_THROW_ON_ERROR), 'version' => $record ? $record->version + 1 : 1, 'updated_at' => now()]; if ($kind !== 'locations' && $documentId) { $values['document_id'] = $documentId; diff --git a/app/Services/OpportunityTerritorialPresentation.php b/app/Services/OpportunityTerritorialPresentation.php new file mode 100644 index 00000000..34d348e9 --- /dev/null +++ b/app/Services/OpportunityTerritorialPresentation.php @@ -0,0 +1,28 @@ +path($record->primary_territory_id, true), fn ($node) => in_array($node->location_type, ['province', 'district', 'admin_post', 'locality'], true))); + $mainIds = array_column($path, 'id', 'location_type'); + $differences = []; + foreach ($locations as $location) { + if ($location->deleted_at || empty($location->payload['is_primary']) || !$path) continue; + $p = $location->payload; + $different = false; + foreach ($mainIds as $level => $id) if (!empty($p[$level.'_id']) && $p[$level.'_id'] !== $id) $different = true; + $foreign = !empty($p['country_code']) && $p['country_code'] !== 'MZ'; + if ($foreign || $different) { + $differences[] = ['resource_id' => $location->id, 'title' => $p['title'] ?? 'Localização sem título', + 'message' => 'A referência principal da ficha e esta localização documentada apontam para territórios diferentes. Confirme se a distinção é intencional; nenhum valor foi substituído.']; + } + } + return ['path' => $path, 'differences' => $differences]; + } +} diff --git a/app/Services/PortalProfileInstallation.php b/app/Services/PortalProfileInstallation.php index 422649f8..493faf3b 100644 --- a/app/Services/PortalProfileInstallation.php +++ b/app/Services/PortalProfileInstallation.php @@ -25,7 +25,18 @@ class PortalProfileInstallation return hash('sha256', json_encode(array_map(fn ($path) => hash_file('sha256', $path), array_unique($files)), JSON_THROW_ON_ERROR)); } - public function authorize(User $actor, bool $write = false): void + public function designatedBootstrap(User $actor): bool + { + $emails = config('portal_profiles.bootstrap_designated_emails', []); + if (! config('portal.homologation_only', false) || count(array_unique($emails)) !== 2 + || ! in_array(strtolower(trim($actor->email)), $emails, true)) { + return false; + } + $target = config('portal_profiles.bootstrap_designated_database', ''); + return $target !== '' && DB::selectOne('SELECT current_database() AS database')->database === $target; + } + + public function authorize(User $actor, bool $write = false, bool $bootstrapOnly = false): void { abort_unless($actor->hasVerifiedEmail(), 403); if (Schema::hasTable('portal_role_assignments')) { @@ -33,7 +44,7 @@ class PortalProfileInstallation ->where('starts_at', '<=', now())->where(fn ($q) => $q->whereNull('expires_at')->orWhere('expires_at', '>', now()))->exists(), 403, 'A restrição do Auditor prevalece.'); } $pair = Schema::hasTable('portal_superuser_pair') ? DB::table('portal_superuser_pair')->where('id', 1)->first() : null; - abort_unless($pair ? in_array($actor->id, [(int) $pair->first_user_id, (int) $pair->second_user_id], true) : $actor->can('manage-portal'), 403, 'Gestor de instalação ou titular da dupla necessário.'); + abort_unless($pair ? in_array($actor->id, [(int) $pair->first_user_id, (int) $pair->second_user_id], true) : ($actor->can('manage-portal') || ($bootstrapOnly && $this->access->installed() && $this->designatedBootstrap($actor))), 403, 'Gestor de instalação ou titular designado necessário.'); if ($write) { abort_unless($this->access->mfa($actor), 403, 'Conclua MFA nesta sessão; configuração de MFA isoladamente não autoriza a operação.'); } @@ -42,7 +53,7 @@ class PortalProfileInstallation /** @return array */ public function preview(User $actor): array { - $this->authorize($actor); + $this->authorize($actor, false, true); $identity = DB::selectOne('SELECT current_database() AS database,inet_server_port() AS port'); $ledger = DB::table('migrations')->where('migration', self::MIGRATION)->exists(); $schema = []; @@ -126,7 +137,7 @@ class PortalProfileInstallation public function bootstrap(User $actor, array $input): array { - $this->authorize($actor, true); + $this->authorize($actor, true, true); $data = Validator::make($input, ['hash' => 'required|string|size:64', 'confirm' => 'required|accepted', 'idempotency_key' => 'required|uuid', 'first_user_id' => 'required|integer|exists:users,id', 'second_user_id' => 'required|integer|different:first_user_id|exists:users,id', 'reason' => 'required|string|min:20|max:1000'])->validate(); abort_unless((int) $data['first_user_id'] === $actor->id, 403, 'O primeiro titular deve apresentar o seu próprio pedido; não é permitido inicializar terceiros.'); @@ -135,6 +146,9 @@ class PortalProfileInstallation abort_unless(hash_equals($p['hash'], $data['hash']), 409, 'Plano alterado.'); abort_unless($p['installed'] && $p['consistent'] && ! in_array(false, $p['dependencies'], true) && ! $p['bootstrap']['initialized'], 409, 'Schema consistente instalado e dupla não inicializada necessários.'); $second = User::findOrFail($data['second_user_id']); + if ($this->designatedBootstrap($actor)) { + abort_unless($this->designatedBootstrap($second), 403, 'Use os dois titulares expressamente designados para este destino.'); + } foreach ([$actor, $second] as $user) app(PortalSuperusers::class)->validateBootstrapHolder($user); abort_if(DB::table('portal_security_approvals')->where('kind', 'bootstrap')->whereNull('consumed_at')->where('expires_at', '>', now())->exists(), 409, 'Já existe pedido pendente. Recupere-o; não duplique a cerimónia.'); $id = (string) \Illuminate\Support\Str::uuid(); @@ -180,7 +194,7 @@ class PortalProfileInstallation abort_unless(hash_equals($view['hash'], $data['hash']), 409, 'Pedido alterado; consulte novamente.'); abort_unless($view['can_confirm'], 409, 'Pedido expirado, já consumido ou revisão/destino alterados.'); $first = User::findOrFail($row->requested_by); - $this->authorize($first); + $this->authorize($first, false, true); $p = $this->preview($first); abort_unless($p['installed'] && $p['consistent'] && ! in_array(false, $p['dependencies'], true) && ! $p['bootstrap']['initialized'], 409, 'Dependências ou dupla alteradas.'); app(PortalSuperusers::class)->validateBootstrapHolder($first); diff --git a/app/Services/ScopedPortalAdministration.php b/app/Services/ScopedPortalAdministration.php new file mode 100644 index 00000000..d144c146 --- /dev/null +++ b/app/Services/ScopedPortalAdministration.php @@ -0,0 +1,50 @@ +hasVerifiedEmail()) return false; + $access = app(PortalProfileAccess::class); + if ($access->has($actor, 'auditor')) return false; + return ($access->superuser($actor) && $access->mfa($actor)) + || ($access->legacyWriteAllowed($actor) && $access->mfa($actor)); + } + + public function interestOperator(?User $actor): bool + { + if (!$actor || !$actor->hasVerifiedEmail()) return false; + $access=app(PortalProfileAccess::class); + return !$access->has($actor,'auditor') && $access->mfa($actor) + && ($this->administrative($actor) || DB::table('interest_requests')->where('assigned_to',$actor->id)->whereNull('deleted_at')->exists()); + } + + public function interest(User $actor, InterestRequest $record): bool + { + return $this->interestOperator($actor) + && ($actor->can('manage-portal') || (int)$record->assigned_to===$actor->id); + } + + public function interestQuery($query, User $actor): void + { + if (!$actor->can('manage-portal')) $query->where('assigned_to',$actor->id); + } + + public function deskDecision(User $actor, object $request): bool + { + return $actor->can('manage-portal') + || ($this->administrative($actor) && (int)$request->analyst_id===$actor->id); + } + + public function deskCaseDecision(User $actor, string $id): bool + { + return $actor->can('manage-portal') || ($this->administrative($actor) + && DB::table('desk_requests')->where('case_id',$id)->where('analyst_id',$actor->id)->exists()); + } +} diff --git a/app/Services/VerifiedDocumentBinary.php b/app/Services/VerifiedDocumentBinary.php index 231abd38..00cdcb84 100644 --- a/app/Services/VerifiedDocumentBinary.php +++ b/app/Services/VerifiedDocumentBinary.php @@ -59,9 +59,10 @@ class VerifiedDocumentBinary if ($table === 'opportunity_images') { $query->orWhereRaw("payload->'derivatives' @> ?::jsonb", [json_encode([['document_id' => $document->id]], JSON_THROW_ON_ERROR)]); } - $links = $links->concat($query->get(['opportunity_id', 'deleted_at'])); + $links = $links->concat($query->get(['opportunity_id', 'deleted_at', 'payload'])); } $privateOrigin = in_array($document->source_system ?? null, ['opportunity-resources', 'opportunity-image-derivatives'], true); + if (! $privateOrigin) $links = $links->reject(fn ($link) => isset(json_decode($link->payload, true)['library_source'])); if ($links->isEmpty() && ! $privateOrigin) { return; // Other consumers retain their own publication/authorization rules. } diff --git a/app/Services/VirtualDesk.php b/app/Services/VirtualDesk.php index 89e38a8b..aeeb6498 100644 --- a/app/Services/VirtualDesk.php +++ b/app/Services/VirtualDesk.php @@ -388,7 +388,7 @@ class VirtualDesk abort_if($this->owns($actor, $case, false), 403, 'O proponente não pode analisar ou decidir o seu próprio pedido.'); abort_unless($actor->can('manage-portal') || (int) $request->analyst_id === $actor->id, 403); if (in_array($data['status'], ['accepted', 'rejected', 'closed'], true)) { - abort_unless($actor->can('manage-portal'), 403); + abort_unless(app(ScopedPortalAdministration::class)->deskDecision($actor,$request), 403); } abort_unless(in_array($data['status'], config('virtual-desk.transitions.'.$request->status, []), true), 409, 'Transição inválida.'); } @@ -412,13 +412,13 @@ class VirtualDesk return DB::transaction(function () use ($actor, $id, $data) { $case = $this->case($actor, $id, false, true); - abort_unless($this->owns($actor, $case) || $actor->can('manage-portal'), 403); + abort_unless($this->owns($actor, $case) || app(ScopedPortalAdministration::class)->deskCaseDecision($actor,$case->id), 403); abort_unless((int) $case->version === (int) $data['version'], 409); $workflow = json_decode($case->workflow, true, 512, JSON_THROW_ON_ERROR); $current = $workflow[$data['stage']]; $decision = in_array($data['status'], ['completed', 'not_applicable'], true); if ($decision) { - abort_unless($actor->can('manage-portal') && ! $this->owns($actor, $case, false), 403, 'Conclusão documental exige revisão interna por outra pessoa autorizada.'); + abort_unless(app(ScopedPortalAdministration::class)->deskCaseDecision($actor,$case->id) && ! $this->owns($actor, $case, false), 403, 'Conclusão documental exige revisão interna por outra pessoa autorizada.'); abort_unless(trim($data['evidence'] ?? '') !== '', 422, 'Identifique a evidência documental.'); foreach ($current['dependencies'] as $dependency) { abort_unless(in_array($workflow[$dependency]['status'], ['completed', 'not_applicable'], true) || (($data['exception'] ?? false) && ($data['document_id'] ?? null)), 422, 'Dependência '.$dependency.' pendente; uma exceção exige evidência própria.'); diff --git a/bootstrap/app.php b/bootstrap/app.php index a4b2495e..be108a69 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -19,7 +19,7 @@ use Illuminate\Support\Facades\Log; use Illuminate\Support\Str; use Symfony\Component\HttpFoundation\Response; -$app = Application::configure(basePath: dirname(__DIR__)) +return Application::configure(basePath: dirname(__DIR__)) ->withRouting( web: __DIR__.'/../routes/web.php', api: __DIR__.'/../routes/api.php', @@ -30,6 +30,7 @@ $app = Application::configure(basePath: dirname(__DIR__)) $middleware->encryptCookies(except: ['appearance', 'sidebar_state', EnsureSimulationRecoveryCookie::NAME]); $middleware->web(append: [ + \App\Http\Middleware\EnsureHomologationAccess::class, PortalProfileSession::class, ProtectPrivilegedIdentity::class, AuditProfileDecision::class, @@ -106,7 +107,3 @@ $app = Application::configure(basePath: dirname(__DIR__)) return $response; }); })->create(); - -(require __DIR__.'/storage.php')($app->basePath(), $app->storagePath()); - -return $app; diff --git a/config/homologation_editorial.php b/config/homologation_editorial.php new file mode 100644 index 00000000..e5cc2470 --- /dev/null +++ b/config/homologation_editorial.php @@ -0,0 +1,157 @@ + env('INVESTMOZ_HOMOLOGATION_ACCESS_MODE', 'reviewers'), + 'homologation_only' => (bool) env('INVESTMOZ_HOMOLOGATION_ONLY', false), + 'homologation_reviewers' => array_values(array_filter(array_map( + fn ($email) => strtolower(trim($email)), + explode(',', (string) env('INVESTMOZ_HOMOLOGATION_REVIEWERS', '')), + ))), 'organization_country_enabled' => env('PORTAL_ORGANIZATION_COUNTRY_ENABLED', false), 'legacy_content_enabled' => env('PORTAL_LEGACY_CONTENT_ENABLED', false), 'legacy_content_qa_enabled' => env('PORTAL_LEGACY_CONTENT_QA_ENABLED', false), @@ -14,6 +22,8 @@ return [ 'opportunity_modular_profiles_qa_enabled' => env('PORTAL_OPPORTUNITY_MODULAR_PROFILES_QA_ENABLED', false), 'opportunity_other_qa_subtypes' => [], 'operator_name' => env('PORTAL_OPERATOR_NAME'), + // Tempo, 19/10/2026: institutional contact; commercial department remains separate. + 'institutional_contact_email' => env('PORTAL_INSTITUTIONAL_CONTACT_EMAIL', 'info@tempo.co.mz'), 'support' => [ 'email' => env('PORTAL_SUPPORT_EMAIL'), 'phone' => env('PORTAL_SUPPORT_PHONE'), @@ -23,6 +33,10 @@ return [ 'notifications_enabled' => env('PORTAL_NOTIFICATIONS_ENABLED', false), 'notification_recipients' => array_values(array_filter(array_map('trim', explode(',', (string) env('PORTAL_NOTIFICATION_RECIPIENTS', ''))))), // Explicit access list; public registration never grants management access. + 'document_managers' => array_values(array_filter(array_map( + fn ($email) => strtolower(trim($email)), + explode(',', (string) env('PORTAL_DOCUMENT_MANAGER_EMAILS', '')), + ))), 'managers' => array_values(array_filter(array_map( fn ($email) => strtolower(trim($email)), explode(',', (string) env('PORTAL_MANAGER_EMAILS', '')), diff --git a/config/portal_profiles.php b/config/portal_profiles.php index 7d53481c..54f6d15b 100644 --- a/config/portal_profiles.php +++ b/config/portal_profiles.php @@ -1,6 +1,9 @@ array_values(array_filter(array_map(fn ($email) => strtolower(trim($email)), explode(',', (string) env('INVESTMOZ_PROFILE_BOOTSTRAP_HOLDERS', ''))))), + 'bootstrap_designated_database' => (string) env('INVESTMOZ_PROFILE_BOOTSTRAP_DATABASE', ''), 'proof_directory' => storage_path('app/private/investmoz-v2/portal-profile-installation'), // Scoped execution flags are set and reset only by the authenticated services. 'authenticated_installation' => false, diff --git a/config/services.php b/config/services.php index d5e36676..febaaea2 100644 --- a/config/services.php +++ b/config/services.php @@ -4,6 +4,7 @@ return [ 'malware' => [ 'scanner' => env('MALWARE_SCANNER', 'clamd'), + 'socket' => env('CLAMD_SOCKET'), 'host' => env('CLAMD_HOST', '127.0.0.1'), 'port' => (int) env('CLAMD_PORT', 3310), 'timeout_seconds' => (int) env('CLAMD_TIMEOUT_SECONDS', 120), diff --git a/database/migrations/2026_10_16_130000_add_investment_zone_editorial_draft.php b/database/migrations/2026_10_16_130000_add_investment_zone_editorial_draft.php index 7f4831b9..a27f2ac3 100644 --- a/database/migrations/2026_10_16_130000_add_investment_zone_editorial_draft.php +++ b/database/migrations/2026_10_16_130000_add_investment_zone_editorial_draft.php @@ -8,7 +8,7 @@ use Illuminate\Support\Facades\Schema; return new class extends Migration { public function up(): void { - if (!config('investment-zone-profile.authenticated_installation') && !\App\Support\IsolatedQaDatabase::allowsSchemaChanges()) { throw new RuntimeException('Use a ação autenticada de instalação nominal da ficha.'); } + if (!config('investment-zone-profile.authenticated_installation')) { throw new RuntimeException('Use a ação autenticada de instalação nominal da ficha.'); } Schema::table('investment_zones',fn(Blueprint $table)=>$table->text('editorial_draft')->nullable()); } public function down(): void diff --git a/database/migrations/2026_10_16_140000_add_investment_zone_operational_details.php b/database/migrations/2026_10_16_140000_add_investment_zone_operational_details.php index 40d5bc67..91159479 100644 --- a/database/migrations/2026_10_16_140000_add_investment_zone_operational_details.php +++ b/database/migrations/2026_10_16_140000_add_investment_zone_operational_details.php @@ -8,7 +8,7 @@ use Illuminate\Support\Facades\Schema; return new class extends Migration { public function up(): void { - if(!config('investment-zone-details.authenticated_installation') && !\App\Support\IsolatedQaDatabase::allowsSchemaChanges()){throw new RuntimeException('Use a instalação autenticada nominal L1.');} + if(!config('investment-zone-details.authenticated_installation')){throw new RuntimeException('Use a instalação autenticada nominal L1.');} if(!Schema::hasColumn('investment_zones','editorial_draft') || !DB::table('migrations')->where('migration','2026_10_16_130000_add_investment_zone_editorial_draft')->exists()){throw new RuntimeException('Instale/verifique primeiro a base 130000.');} Schema::table('investment_zones',fn(Blueprint $table)=>$table->jsonb('operational_details')->nullable()); } diff --git a/database/migrations/2026_10_16_150000_create_investor_navigation_preferences.php b/database/migrations/2026_10_16_150000_create_investor_navigation_preferences.php index 0f773e52..f58cb54a 100644 --- a/database/migrations/2026_10_16_150000_create_investor_navigation_preferences.php +++ b/database/migrations/2026_10_16_150000_create_investor_navigation_preferences.php @@ -8,7 +8,7 @@ return new class extends Migration { public function up(): void { - if (! config('investor-context.authenticated_installation') && ! \App\Support\IsolatedQaDatabase::allowsSchemaChanges()) { + if (! config('investor-context.authenticated_installation')) { throw new RuntimeException('Use a ação autenticada nominal de instalação do contexto.'); } Schema::create('investor_navigation_preferences', function (Blueprint $t) { diff --git a/database/migrations/2026_10_18_160000_add_portal_editorial_content.php b/database/migrations/2026_10_18_160000_add_portal_editorial_content.php index d557490d..c785fb4b 100644 --- a/database/migrations/2026_10_18_160000_add_portal_editorial_content.php +++ b/database/migrations/2026_10_18_160000_add_portal_editorial_content.php @@ -9,7 +9,7 @@ return new class extends Migration { public function up(): void { - if (!config('editorial.authenticated_installation') && !\App\Support\IsolatedQaDatabase::allowsSchemaChanges()) { throw new RuntimeException('Use a instalação editorial autenticada.'); } + if (!config('editorial.authenticated_installation')) { throw new RuntimeException('Use a instalação editorial autenticada.'); } Schema::table('portal_resources', fn (Blueprint $t) => $t->jsonb('editorial_content')->nullable()); } diff --git a/deploy/homologacao/reconciled-baseline.json b/deploy/homologacao/reconciled-baseline.json new file mode 100644 index 00000000..86af7482 --- /dev/null +++ b/deploy/homologacao/reconciled-baseline.json @@ -0,0 +1,442 @@ +{ + "base_commit": "507387502c6ef887433e8e9b5cf20ea39d4f17d4", + "installed_revision": "423bd512d8c5", + "approved_runtime_files_adopted": [ + { + "path": "app/Http/Controllers/Api/V1/InterestRequestController.php", + "remote_sha256": "50b65d0dd576d1c0e4357fe5a192744900eb77b2e5737191ecc553428e6ccf14" + }, + { + "path": "app/Http/Controllers/DocumentMalwareController.php", + "remote_sha256": "b19b028ae53fcb8ed648745c9de0121e128e489a9cdd1c3eb8a4210c297416d4" + }, + { + "path": "app/Http/Controllers/EditorialArticleController.php", + "remote_sha256": "41e34a865890c81191041ec1c34b0abc1ab052c99ae3b96edc930f841c5a33c5" + }, + { + "path": "app/Http/Controllers/ExistingBusinessController.php", + "remote_sha256": "e73ac0fbe1c4ae3939c27e5ca1b6e76642611a5c117c7473f21c284235fe75da" + }, + { + "path": "app/Http/Controllers/InvestmentSubmissionController.php", + "remote_sha256": "257837ff8100d5fbc3a432ec336aacc4f37af01d7044e0c44d37de2d4ec44b4c" + }, + { + "path": "app/Http/Controllers/InvestmentZoneController.php", + "remote_sha256": "32ed3b3f662b7fcfb8fce09c7626680f3409b0b3bd6acd662088e0adc22cf53b" + }, + { + "path": "app/Http/Controllers/OpportunityResourceController.php", + "remote_sha256": "da265c473a783423321fa75f1b02eb62f311b80e4cb317298d5ac4883630e2c6" + }, + { + "path": "app/Http/Controllers/PortalManagementController.php", + "remote_sha256": "c49ba1d490f88e65daae21ad0038608a71aef547d7b77acd096cdc8a4d695c0d" + }, + { + "path": "app/Http/Controllers/PortalServicesController.php", + "remote_sha256": "8e7595a42945876f9d2787a8fafaa7cec43c6dfafdc79a9aee73fa8aa533c359" + }, + { + "path": "app/Http/Controllers/VirtualDeskController.php", + "remote_sha256": "e4620b41820945512283f7f0cdd46f70916b9935288ec91a00e3d1c388af950d" + }, + { + "path": "app/Http/Middleware/EnsureDocumentManagement.php", + "remote_sha256": "7e373bffc2517366066a32889b36f277f5cea242c0f3277ef291e2c98fb8520e" + }, + { + "path": "app/Http/Middleware/EnsureHomologationAccess.php", + "remote_sha256": "259f467b08d4ad5a1605a40c5f3ccb9e41b2a879643df1f8e4814f5d0c82a8b4" + }, + { + "path": "app/Http/Middleware/EnsureScopedEditorial.php", + "remote_sha256": "62c4c16506f5e75ef76e3fa987d6b5ba42d5df953604f1aeecb89c87c7c5c7ca" + }, + { + "path": "app/Http/Middleware/EnsureScopedPortalAdministration.php", + "remote_sha256": "3842b05ddd4433b5a26c0dea6b97542b2b3846f46f0393c5515872ded43f0f61" + }, + { + "path": "app/Http/Middleware/HandleInertiaRequests.php", + "remote_sha256": "01083ba8e8aec0f0a346e6f86739155122b52fd44f736c066683f0c04c9bdc73" + }, + { + "path": "app/Http/Middleware/VerifiedOrSimpleParticipation.php", + "remote_sha256": "301e0ce37a92e0985bfb5b914aa13a32a0081f8ead88842ff4911c043553780a" + }, + { + "path": "app/Http/Requests/StoreInterestRequestRequest.php", + "remote_sha256": "a064fc0c71139b6ced9d209a490c1f339733ae9ad513c0128520b2942384eeac" + }, + { + "path": "app/Policies/ExistingBusinessPolicy.php", + "remote_sha256": "69c75db509d876d6125da7d4254ac3be6e23e0ee942dea460bd86fc73aa02def" + }, + { + "path": "app/Services/BusinessFormSchema.php", + "remote_sha256": "bf7509574c8c66da49b2f2d488a4dbc9896a15daebd78404fa4832c0322fe6f7" + }, + { + "path": "app/Services/DeclaredOriginStatistics.php", + "remote_sha256": "b5a20759938e2d5652018d6ec0b1b1d8dda6cdddbd4f7767fd5dbfa0da0e2f4c" + }, + { + "path": "app/Services/DocumentManagementAccess.php", + "remote_sha256": "6b2a0e8b75cb99f8baa9f40b4eda7cd5171348dd2126833ca3399e252baed4d0" + }, + { + "path": "app/Services/EconomicReferenceInstallation.php", + "remote_sha256": "a22050130c8cefdb18f2dafe03a4158f3e1596c5158cb9fd262abbf63c5ab144" + }, + { + "path": "app/Services/ExistingBusinessIntake.php", + "remote_sha256": "30d2325030e4024da1a15bc08085db8037fa42d09b1ece9c071386f346597937" + }, + { + "path": "app/Services/ExistingBusinessProfileCategory.php", + "remote_sha256": "46c045c19bff743a507a2efe0fc333454e82a9a48c2b355cf56834b638951737" + }, + { + "path": "app/Services/InterestWorkflow.php", + "remote_sha256": "d29080a7757c07505b418d4148cdb4abaaeb213a7b004bc21c52a135af99dca8" + }, + { + "path": "app/Services/InvestmentZoneOperationalDetailsInstallation.php", + "remote_sha256": "2b9f47cf7e1ddf14069440691485733f29cbe5fc0fb5b0091d15e96f5b8391d9" + }, + { + "path": "app/Services/InvestmentZoneProfileInstallation.php", + "remote_sha256": "026d6e1752fc8c4c80c14d5ce87bf5ea53d585a917a0cd3cd2c9d522247bb7db" + }, + { + "path": "app/Services/InvestorContext.php", + "remote_sha256": "1e7d9c7096b72a23df17611247c315ae6c8e76472c87638409be987c3cbba273" + }, + { + "path": "app/Services/LegacyContent.php", + "remote_sha256": "7138d214165859ad9ae4f4be81de9ab39978257264025a357b9b84aa90e661bc" + }, + { + "path": "app/Services/Malware/ClamDScanner.php", + "remote_sha256": "b708454c18793cbdfa761010905a0e423adfbaddeaf49ac342d88b1dc2e5d3e0" + }, + { + "path": "app/Services/OpportunityCanonicalContract.php", + "remote_sha256": "75ffb95495b630193710830f5b9913681f544baacebeac7e7be5d7f7815e59cd" + }, + { + "path": "app/Services/OpportunityCategoryFields.php", + "remote_sha256": "d7d294c72b7c416bd91726e7d7e2a045968e85d62ddaf57137da921e0340f25c" + }, + { + "path": "app/Services/OpportunityCompleteness.php", + "remote_sha256": "1497712c221332e5f879205d94470c5cbe657d543565a99f08eee4543da0ddfb" + }, + { + "path": "app/Services/OpportunityInitiativeApplicability.php", + "remote_sha256": "7efeae3c445bc3e7feb3275bd66a54a0b1370931a1331ac5ef6d7bfa58b4cdb5" + }, + { + "path": "app/Services/OpportunityLibrarySupport.php", + "remote_sha256": "2dea4f3efc76b16c4cccb97ffcfbeccbe4b3151a9a871fe8e2c175f7874fec11" + }, + { + "path": "app/Services/OpportunityResources.php", + "remote_sha256": "cf3baa0e5592f0b039189877faca514c62db65ae2e2bd634b5380c74f56ab412" + }, + { + "path": "app/Services/OpportunityTerritorialPresentation.php", + "remote_sha256": "81d91ffe07531c43e057c81887bfa2891e50c38ff7cda9414a26af67592a2b78" + }, + { + "path": "app/Services/PortalProfileInstallation.php", + "remote_sha256": "4ab58a07c93e743327c592434dc1931057baf1ba81f488f4b51a28022beec4ac" + }, + { + "path": "app/Services/ScopedPortalAdministration.php", + "remote_sha256": "013efc04ecfddaea7473f50c0d0c31f56b5d958bb1594ed913d3d3855b9b0372" + }, + { + "path": "app/Services/VerifiedDocumentBinary.php", + "remote_sha256": "3a602b504bcb26edd3c9182dcab83fef2fd25166e61a840d1503b7a4d98f0728" + }, + { + "path": "app/Services/VirtualDesk.php", + "remote_sha256": "dc958b808d91b353d2c8e7d1244f594c54fc5eb4924e64654818f1cbd1aebda9" + }, + { + "path": "bootstrap/app.php", + "remote_sha256": "b6e04e06d1d7d17f43ca81370ced7d470d5b9274957680110203f20e44ffacc1" + }, + { + "path": "config/homologation_editorial.php", + "remote_sha256": "451a24a9a93fcdc2fd2aec98fb5b8689e13de1239c9b1671906ec58eb1b4e09e" + }, + { + "path": "config/legacy_content_aliases.php", + "remote_sha256": "c8bfbead3a0908c84f7e1d595154d6d75ecebd24737651dad396e8bd0c900c50" + }, + { + "path": "config/portal.php", + "remote_sha256": "6f26c86f4599a93eb1b80bb6fa0657b72fdbc03935d2c262080858fd79287b00" + }, + { + "path": "config/portal_profiles.php", + "remote_sha256": "2e9e3d40277980c9fc139a51eb33212fe3df71a74397c353fe355a5642f99a07" + }, + { + "path": "config/services.php", + "remote_sha256": "98f2875d58ab9283018b5c8e3ba24000f2d2b8be4d67ff107ff33ceac578be7c" + }, + { + "path": "database/migrations/2026_10_16_130000_add_investment_zone_editorial_draft.php", + "remote_sha256": "ff9b56f7c3d156bedfe0050953c2ea6c10811fd859524a8fb994e176527846f0" + }, + { + "path": "database/migrations/2026_10_16_140000_add_investment_zone_operational_details.php", + "remote_sha256": "3d3e7f00df749475a67c4d098a42cbbc81f9015afc8b74e6be58c86dd1f2abfb" + }, + { + "path": "database/migrations/2026_10_16_150000_create_investor_navigation_preferences.php", + "remote_sha256": "e12017e6a4d1a960057cf2d1df9dbd69faa867bf6a2d08a50dd3089a6a967100" + }, + { + "path": "database/migrations/2026_10_18_160000_add_portal_editorial_content.php", + "remote_sha256": "b3aa3d2510cd17975dbb4ff5f49d741af9f67601ce7e181f6173cfa2368d6334" + }, + { + "path": "resources/js/app.tsx", + "remote_sha256": "858eabfe3f5bf45a2e5a11c08e123fdd8c3243c6648c0d54def6966a0a683f88" + }, + { + "path": "resources/js/components/app-logo.tsx", + "remote_sha256": "e8e95de1d180014edc8fce2a36851727e5721319a9e9dffa90c82734f7b5939c" + }, + { + "path": "resources/js/components/context-documents.tsx", + "remote_sha256": "516871523445ea1efd2415f73c63e1ae74be2da76ceb620708bdf39fe0605999" + }, + { + "path": "resources/js/components/country-select.tsx", + "remote_sha256": "291676dc034a5f3d4d6498f1b80aa9de01b2b256f092fc855177a232b29ec714" + }, + { + "path": "resources/js/components/investment-map-panel.tsx", + "remote_sha256": "7cee11792343dd3ac94d4aeb782e6b23a29cf826b56209dcc5b4291d57b738c5" + }, + { + "path": "resources/js/components/investment-territory.tsx", + "remote_sha256": "38b2945a59601c5a1cea33827f57f105fc521ed3679223d95d5aa76cf6e13855" + }, + { + "path": "resources/js/components/opportunity-canonical-fields.tsx", + "remote_sha256": "f3c763063eee9aeb2da354c54e84c86e3c54f46dfecce4665ee0e8c6c6970d05" + }, + { + "path": "resources/js/components/opportunity-classification.tsx", + "remote_sha256": "06bdcf091f51a7eb44f75790f7d40462172571d1f1219f12620ee84a64fd668e" + }, + { + "path": "resources/js/components/opportunity-library-reuse.tsx", + "remote_sha256": "e95e3358a0016edd00596b09eeb80f5c2f0d149bdb456d8bc2c812cfb32e62f7" + }, + { + "path": "resources/js/components/partnership-search-results.tsx", + "remote_sha256": "efc037858a9a01d643894b308af8145959a7111b2667e393392dae53a3e5ba88" + }, + { + "path": "resources/js/components/password-input.tsx", + "remote_sha256": "f13f872ca86ebfd48fe475efdea32d5ec667414a4bba4529efe19a3bf81b2c16" + }, + { + "path": "resources/js/components/portal-grouped-menu.tsx", + "remote_sha256": "0c1de9b30772632afadcef6871edd6938cdab8f838983fd00cbd473d135432e7" + }, + { + "path": "resources/js/components/portal-product-identity.tsx", + "remote_sha256": "a0d5e22d06bd9dd12dce152425645f0247c7cb88767c23d5f0367e4f91c1e0cc" + }, + { + "path": "resources/js/components/search-feedback.tsx", + "remote_sha256": "bbe5b489b55b5853de553f68bdc6d38708247ca7a0649dda8a26e60148a8905d" + }, + { + "path": "resources/js/components/search-state.tsx", + "remote_sha256": "70d705f327d561c822a679e0739ce02470fbe7a18e4c8b44c9305f7619b496d6" + }, + { + "path": "resources/js/components/structure-geography.tsx", + "remote_sha256": "62f7570cf0b9149f006721ea59d694d66038530702d368444abc3589128619d4" + }, + { + "path": "resources/js/components/tempo-footer.tsx", + "remote_sha256": "14bbdc61d82cf392b43f7bf35e326cec87ddb40fbcce22d9aab9da872f746bc6" + }, + { + "path": "resources/js/hooks/use-interest-draft.ts", + "remote_sha256": "787b3295f3bee1282b4cacfefc1e7650feea54738d35f542d879a4dc13c9d475" + }, + { + "path": "resources/js/layouts/auth/auth-simple-layout.tsx", + "remote_sha256": "6c2b046bd02e7e02017a536873add53b02df79ef17798476b8021c401546462b" + }, + { + "path": "resources/js/layouts/portal-navigation.tsx", + "remote_sha256": "08876167e7de425470d6b97133441221437081b2065b2f1e6ccc9efce40a0b45" + }, + { + "path": "resources/js/lib/investor-context.ts", + "remote_sha256": "19e2e5e6ef85059c29354a0f2a14d26012fdcc9868b84144b18df5fc41a667f9" + }, + { + "path": "resources/js/lib/participation-identity.ts", + "remote_sha256": "e4446f51529d787e83beeb044eede9a3e5f3894cf7f9c51d97542c403f563b6c" + }, + { + "path": "resources/js/lib/portal-api.ts", + "remote_sha256": "2ad0ad502e7805ae834e2368982f17b90cd9e32e4c0de112d14c1f3ef9268ca8" + }, + { + "path": "resources/js/lib/portal-navigation.ts", + "remote_sha256": "17e541f5f0add27bf4af1dc6dae0ce7cdeab96de0162f1b3663cbf4dea2d8c85" + }, + { + "path": "resources/js/lib/project-dossier-document.ts", + "remote_sha256": "28e2ac9929038f9fdbc2fe5bf8dde46e4ff477db29964dc7921381bfbf00cc65" + }, + { + "path": "resources/js/lib/project-dossier-report.ts", + "remote_sha256": "f4a428cd337912df60ec3029e95c6fe9beaf2d3f81cca903690c626828719f22" + }, + { + "path": "resources/js/pages/PortalInvestidor.tsx", + "remote_sha256": "18885a653fe13ea9040a998fb721a9e21a77fc3ead359378fac7b7a740eb9c5d" + }, + { + "path": "resources/js/pages/auth/login.tsx", + "remote_sha256": "3f9231ba2cb69216c61215c701e8929ff9b90fb164f9a179f42d00b7c30c3aa7" + }, + { + "path": "resources/js/pages/auth/register.tsx", + "remote_sha256": "6ea92f1ec50058e39af8901b78256f4c99fcfbefd0f9ebb299acdff05c99d50a" + }, + { + "path": "resources/js/pages/desk/request.tsx", + "remote_sha256": "7b3b8bec1a66a0403e6cdf9a11cf83d59b6a529f5f43b6a5871ba76c1bc7935d" + }, + { + "path": "resources/js/pages/investment/business-interest.tsx", + "remote_sha256": "0cfbde1ba8e4cd164bd2a904c9f5560e4603609c71854b3fdb6db91561b883c4" + }, + { + "path": "resources/js/pages/investment/business-wizard.tsx", + "remote_sha256": "03ce4217bcb34583ceeeab454e40b1ee8aa73ba361d24e833e3c16e5fa165ec4" + }, + { + "path": "resources/js/pages/investment/business.tsx", + "remote_sha256": "3682717821510a19f7e02c14179d72144f50ee822d96ebd011e48d37d6ab2055" + }, + { + "path": "resources/js/pages/investment/catalogue.tsx", + "remote_sha256": "7ac06e80303992fae94b6ba2313ede5cff13da974815460cf59100b07b1ce338" + }, + { + "path": "resources/js/pages/investment/context.tsx", + "remote_sha256": "50b86276a3ac238b678f06dfb536b61ca3711a495453debae2e795082cdc0053" + }, + { + "path": "resources/js/pages/investment/detail.tsx", + "remote_sha256": "743de540cd1caeda5f3afb2b2dc78c088ea56ebe527a5466a3d039b430548688" + }, + { + "path": "resources/js/pages/investment/submission.tsx", + "remote_sha256": "71f03043fd16a806632fa7c0bf503998f0dff99fdd1bbfcaa703a032a2fa6558" + }, + { + "path": "resources/js/pages/investment/zones.tsx", + "remote_sha256": "a1f0b395f027a05ecef134e1bd0c03a20a23b089c7d8c5aa45403eb1c7920a92" + }, + { + "path": "resources/js/pages/investor-path/context-start.tsx", + "remote_sha256": "36d95c80a64a09ee40bd0fec8f6c0f623a47e576667fe08fadf2e3dc8969691e" + }, + { + "path": "resources/js/pages/investor-path/context.tsx", + "remote_sha256": "823da4d6092f553fef7aa801fb1c7d809b53b2c96571aaaeffece67a92af454a" + }, + { + "path": "resources/js/pages/investor-path/show.tsx", + "remote_sha256": "5d56de96fe41ad5b8782a4f1c472c110cbb3e93493bc92f88626853a7dff563d" + }, + { + "path": "resources/js/pages/land-assistance/print.tsx", + "remote_sha256": "6355e9adb55de69deb335abff1c5d872e8993845c329f3227b81cfee4f7484c8" + }, + { + "path": "resources/js/pages/management/desk-assignments.tsx", + "remote_sha256": "4ce516c67caf67215a8f9fa2cb5ca7e7c959353fc0c0b661ef31c47751d97274" + }, + { + "path": "resources/js/pages/management/opportunity-draft.tsx", + "remote_sha256": "4281780693b7d25f2d8bb8f764e89555a1bc628b551725b671691fd0a7760f01" + }, + { + "path": "resources/js/pages/management/opportunity-resources.tsx", + "remote_sha256": "494aab642c84e3fd603cf7d0c03d8cbc317e1f52d9575d6bace00360e6334346" + }, + { + "path": "resources/js/pages/management/territory-associations.tsx", + "remote_sha256": "23997e11cc0a6735e128fdabb1453be9022a51487addaa69fe4d301a49cc2725" + }, + { + "path": "resources/js/pages/portal/manage.tsx", + "remote_sha256": "b6b96686aaccf6699551b0090a92fc40a9828affb953a8a87568d35fdc90688b" + }, + { + "path": "resources/js/pages/portal/services.tsx", + "remote_sha256": "8685509a3678323d9471e5921edcd195ca7dd3a50cc301d44222117d5df690e2" + }, + { + "path": "resources/views/desk/receipt.blade.php", + "remote_sha256": "74c52186b38f75cae100283081fad22b90b13ec69ead512c4cd4f91bc2ca3a44" + }, + { + "path": "resources/views/errors/403.blade.php", + "remote_sha256": "608ca8b0af82357afc1e6246767b777d2ec8b260f06f7f667089b1a465b24100" + }, + { + "path": "routes/api.php", + "remote_sha256": "03afdc58de664a5d42459f4dcbd7367788f49c7736ec821ebd4bfaab609c3444" + }, + { + "path": "routes/investment-zones.php", + "remote_sha256": "34b2d9c5042e59e4442906399e72e057771422840655185733fe32e8d7013737" + }, + { + "path": "routes/investment.php", + "remote_sha256": "ce048ba84c38cb4c3676c05f04853ab41686970eadf583ba849ac63d19fce011" + }, + { + "path": "routes/virtual-desk.php", + "remote_sha256": "fcef540ff90f6212b84b097492f34398c585fb03c6aeaa332943234e134cc9cb" + }, + { + "path": "routes/web.php", + "remote_sha256": "67da2d4dd4c2572f42c734bf0e538b86d0deb44180a11e8a999b325159352198" + } + ], + "unchanged_runtime_files": 676, + "runtime_files_requiring_remote_read": [], + "excluded": "All .env, storage, generated route helpers, vendor/node_modules, synthetic actors and records; no database imports. Wayfinder helpers regenerated from committed routes during build.", + "existing_diagnostic_source_preserved": [ + "app/Console/Commands/CheckDevelopmentEnvironment.php" + ], + "generated_helper_not_committed": [ + { + "path": "resources/js/wayfinder/index.ts", + "remote_sha256": "3efc1c9f2eaa4828a4169414a1239420fc1bea8d3752fbd7a95b4a6da4a60d11" + } + ] +} diff --git a/resources/js/app.tsx b/resources/js/app.tsx index db29c10c..6b14af95 100644 --- a/resources/js/app.tsx +++ b/resources/js/app.tsx @@ -8,7 +8,7 @@ import AuthLayout from '@/layouts/auth-layout'; import SettingsLayout from '@/layouts/settings/layout'; import PortalNavigation from '@/layouts/portal-navigation'; -const appName = import.meta.env.VITE_APP_NAME || 'Laravel'; +const appName = import.meta.env.VITE_APP_NAME || 'InvesteMoz'; const pages = import.meta.glob<{ default: ComponentType }>('./pages/**/*.tsx'); void createInertiaApp({ diff --git a/resources/js/components/app-logo.tsx b/resources/js/components/app-logo.tsx index e8fa317e..474e0fbf 100644 --- a/resources/js/components/app-logo.tsx +++ b/resources/js/components/app-logo.tsx @@ -1,11 +1,2 @@ -export default function AppLogo() { - return ( - Tempo - ); -} +import PortalProductIdentity from '@/components/portal-product-identity'; +export default function AppLogo() { return ; } diff --git a/resources/js/components/context-documents.tsx b/resources/js/components/context-documents.tsx new file mode 100644 index 00000000..a3ce22c9 --- /dev/null +++ b/resources/js/components/context-documents.tsx @@ -0,0 +1,54 @@ +import { Link } from '@inertiajs/react'; +import type { ContextReference } from '@/lib/investor-context'; + +export default function ContextDocuments({ + source, +}: { + source?: ContextReference; +}) { + if (!source) return null; + return ( +
+

Fonte e documentos do contexto

+

+ + {source.title} + {' '} + · versão da oportunidade {source.version ?? 'não indicada'} ·{' '} + {source.visibility === 'private' + ? 'Preparação privada — não publicada' + : 'Referência pública'} +

+ {(source.documents ?? []).map((doc) => ( + + ))} + {!source.documents?.length && ( +

Sem suporte da biblioteca associado nesta versão.

+ )} +

+ Referência guardada nesta versão do dossier. A consulta do + original continua sujeita às permissões actuais; esta ligação + não publica nem concede acesso adicional. +

+
+ ); +} diff --git a/resources/js/components/country-select.tsx b/resources/js/components/country-select.tsx index 9c27d680..e21e7616 100644 --- a/resources/js/components/country-select.tsx +++ b/resources/js/components/country-select.tsx @@ -50,6 +50,7 @@ export default function CountrySelect({ onChange={(event) => setSearch(event.target.value)} placeholder="Pesquisar nome ou código do país" /> + {search.trim() &&

{matches.length ? `${matches.length} resultados encontrados` : "Não foram encontrados resultados para esta pesquisa"}

} ))} - {error && ( - - {error}{' '} - - - )} {geometryFeedback &&

{geometryFeedback}

} + {enabled && setRetry(value => value + 1)} />} @@ -370,22 +360,11 @@ export function TerritoryLevel({ ))} {loading &&

A carregar…

} - {error && ( -
- {error}{' '} - -
- )} {fieldError && ( )} - {enabled && !loading && !error && options.length === 0 && ( -

Sem opções para esta pesquisa.

- )}
{page > 1 && (