From 60784a2ed2e61b1e3215c4c13cb6890c9591f91b Mon Sep 17 00:00:00 2001 From: nala Date: Fri, 9 Oct 2026 08:48:42 +0200 Subject: [PATCH] oportunidades 2 --- .../OpportunityResourceController.php | 156 +++++ app/Jobs/ScanDocumentForMalware.php | 55 +- .../OpportunityResourceInstallation.php | 53 ++ app/Services/OpportunityResources.php | 255 ++++++++ ...09_030000_extend_opportunity_resources.php | 64 ++ docs/investmoz/v2/backlog-mestre.md | 4 + .../v2/gestao-oportunidades-dashboard.md | 6 + .../js/pages/management/opportunity-draft.tsx | 193 +++--- .../pages/management/opportunity-install.tsx | 23 +- .../management/opportunity-resources.tsx | 596 ++++++++++++++++++ routes/web.php | 8 + tests/Feature/OpportunityResourcesTest.php | 99 +++ .../opportunity-management-active-preview.php | 17 +- tools/opportunity-resources-qa.php | 92 +++ 14 files changed, 1496 insertions(+), 125 deletions(-) create mode 100644 app/Http/Controllers/OpportunityResourceController.php create mode 100644 app/Services/OpportunityResourceInstallation.php create mode 100644 app/Services/OpportunityResources.php create mode 100644 database/migrations/2026_10_09_030000_extend_opportunity_resources.php create mode 100644 resources/js/pages/management/opportunity-resources.tsx create mode 100644 tests/Feature/OpportunityResourcesTest.php create mode 100644 tools/opportunity-resources-qa.php diff --git a/app/Http/Controllers/OpportunityResourceController.php b/app/Http/Controllers/OpportunityResourceController.php new file mode 100644 index 00000000..e0ac1765 --- /dev/null +++ b/app/Http/Controllers/OpportunityResourceController.php @@ -0,0 +1,156 @@ +preview(), 'actionUrl' => '/management/opportunity-resources-install']; + + return $request->expectsJson() ? response()->json($preview) : Inertia::render('management/opportunity-install', $preview); + } + + public function applyInstallation(Request $request, OpportunityResourceInstallation $service) + { + $data = $request->validate(['hash' => 'required|string|size:64', 'confirm' => 'required|accepted']); + $service->apply($request->user(), $data['hash']); + + return back()->with('success', 'Recursos instalados; nenhum ficheiro publicado ou oportunidade submetida.'); + } + + public function index(int $id, OpportunityResources $service) + { + $opportunity = Opportunity::withTrashed()->findOrFail($id); + abort_unless($service->installed(), 503, 'Recursos ainda não instalados; consulte o plano autenticado.'); + $resources = []; + foreach (OpportunityResources::KINDS as $kind) { + $resources[$kind] = $service->listing($id, $kind); + } + foreach ($resources['locations'] as $location) { + $p = $location->payload; + $location->territoryPath = app(InvestmentGeography::class)->path($p['locality_id'] ?? $p['admin_post_id'] ?? $p['district_id'] ?? $p['province_id'] ?? null, true); + } + + 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)]); + } + + public function save(Request $request, int $id, string $kind, OpportunityResources $service, ?string $resourceId = null) + { + $input = $request->all(); + if (is_string($input['payload'] ?? null)) { + $input['payload'] = json_decode($input['payload'], true); + } + $record = $service->save($request->user(), $id, $kind, $input, $request->file('file'), $resourceId); + + return response()->json(['id' => $record->id, 'message' => 'Recurso guardado no servidor. Não publicado; validação final e scan são independentes.', 'etag' => app(OpportunityManagement::class)->etag(Opportunity::findOrFail($id))], $resourceId ? 200 : 201); + } + + public function lifecycle(Request $request, int $id, string $kind, string $resourceId, OpportunityResources $service) + { + $service->lifecycle($request->user(), $id, $kind, $resourceId, $request->all()); + + return response()->json(['message' => 'Exclusão lógica/restauração registada; original e versões preservados.']); + } + + public function download(int $id, string $kind, string $resourceId, OpportunityResources $service) + { + abort_unless(in_array($kind, ['documents', 'images'], true), 404); + $opportunity = Opportunity::findOrFail($id); + 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 && $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.'); + $disk = Storage::disk('local'); + $path = $disk->path($record->storage_key); + abort_unless($disk->exists($record->storage_key), 409, 'Ficheiro indisponível.'); + $bytes = $record->storage_encryption === 'laravel' ? Crypt::decryptString($disk->get($record->storage_key)) : $disk->get($record->storage_key); + abort_unless(hash_equals(trim($record->sha256), hash('sha256', $bytes)), 409, 'Integridade do ficheiro divergente.'); + + return response($bytes)->header('Content-Type', $record->media_type)->header('Content-Disposition', HeaderUtils::makeDisposition('attachment', $record->file_name, 'document'))->header('X-Content-Type-Options', 'nosniff')->header('Cache-Control', 'no-store'); + } + + /** @param array> $resources + * @return list + */ + private function assessment(array $resources): array + { + $issues = []; + $locations = array_values(array_filter($resources['locations'], fn ($r) => ! $r->deleted_at)); + if (count($locations) < 1) { + $issues[] = 'Localização operacional ainda não preparada; ligações históricas não são convertidas automaticamente.'; + } + if (count(array_filter($locations, fn ($r) => $r->payload['is_primary'] ?? false)) !== 1) { + $issues[] = 'Escolha exatamente uma localização principal.'; + } + $allocated = array_filter($locations, fn ($r) => isset($r->payload['allocation_percentage'])); + if ($allocated && (count($allocated) !== count($locations) || abs(array_sum(array_map(fn ($r) => (float) $r->payload['allocation_percentage'], $allocated)) - 100) > 0.01)) { + $issues[] = 'A distribuição deve abranger todas as localizações e somar 100% (tolerância 0,01).'; + } + $provinces = []; + $districts = []; + foreach ($locations as $location) { + $p = $location->payload; + if (empty($p['province_id']) || empty($p['district_id']) || empty($p['role'])) { + $issues[] = 'Uma localização tem província, distrito ou papel pendente.'; + } + if (! empty($p['district_id'])) { + $districts[$p['district_id']] = true; + } + if (! empty($p['province_id'])) { + $provinces[$p['province_id']] = true; + } + if (($p['role'] ?? null) === 'other' && empty($p['role_description'])) { + $issues[] = 'Papel outro exige descrição.'; + } + if (empty($p['source_reference'])) { + $issues[] = 'Localização com rastreabilidade por recuperar; aceitação oficial anterior preservada.'; + } + if (! empty($p['geometry'])) { + $issues[] = 'Compatibilidade espacial depende de limite oficial correspondente; geometria não confirma disponibilidade.'; + } + if (isset($p['area_value']) !== isset($p['area_unit'])) { + $issues[] = 'Área exige valor e unidade juntos.'; + } + } + if (count($districts) > 1 && ! array_filter($locations, fn ($r) => mb_strlen($r->payload['multi_district_rationale'] ?? '') >= 50)) { + $issues[] = 'Multidistrito/interprovincial exige fundamento operacional de 50–1000 caracteres.'; + } + foreach (['images', 'documents'] as $kind) { + foreach ($resources[$kind] as $r) { + if ($r->deleted_at) { + continue; + } + if ($r->file->malware_scan_status !== 'clean') { + $issues[] = 'Anexo '.$r->file->file_name.': scan '.$r->file->malware_scan_status.'; não é evidência de segurança.'; + } + $required = $kind === 'images' ? ['title', 'caption', 'alt_text', 'author_source', 'rights_status'] : ['category', 'issuer', 'edition', 'classification']; + foreach ($required as $field) { + if (empty($r->payload[$field])) { + $issues[] = 'Anexo '.$r->file->file_name.': '.$field.' pendente.'; + } + } + if ($kind === 'images') { + $issues[] = 'Imagem '.$r->file->file_name.': revisão, derivadas sem EXIF e aprovação de divulgação ainda pendentes.'; + if (in_array($r->payload['rights_status'] ?? null, ['licensed', 'authorized'], true) && empty($r->payload['rights_document_id'])) { + $issues[] = 'Imagem licenciada/autorizada exige comprovativo.'; + } + } + } + } + $issues[] = 'Esta conferência não submete nem publica; workflow final, revisão humana e derivados seguros permanecem separados.'; + + return array_values(array_unique($issues)); + } +} diff --git a/app/Jobs/ScanDocumentForMalware.php b/app/Jobs/ScanDocumentForMalware.php index 134661e9..38eee396 100644 --- a/app/Jobs/ScanDocumentForMalware.php +++ b/app/Jobs/ScanDocumentForMalware.php @@ -9,6 +9,7 @@ use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; +use Illuminate\Support\Facades\Crypt; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Storage; use Illuminate\Support\Str; @@ -67,27 +68,43 @@ class ScanDocumentForMalware implements ShouldBeUnique, ShouldQueue $disk = Storage::disk('local'); $root = $disk->path(''); $path = $disk->path($document->storage_key); - $beforeHash = $this->fileHash($path); - if ($beforeHash === null || ! hash_equals(trim($document->sha256), $beforeHash)) { - $this->record($document, [ - 'result' => 'inconclusive', 'reason_code' => 'file_hash_mismatch', 'engine' => 'ClamAV', - 'engine_version' => 'unknown', 'signatures_at' => null, 'duration_ms' => 0, 'detail' => null, - ], $startedAt, 'inconclusive'); + $temporary = null; + try { + if (($document->storage_encryption ?? null) === 'laravel') { + $temporary = tempnam($root, 'scan-private-'); + if ($temporary === false) { + throw new \RuntimeException('Private scan workspace unavailable'); + } + chmod($temporary, 0600); + file_put_contents($temporary, Crypt::decryptString($disk->get($document->storage_key))); + $path = $temporary; + } + $beforeHash = $this->fileHash($path); + if ($beforeHash === null || ! hash_equals(trim($document->sha256), $beforeHash)) { + $this->record($document, [ + 'result' => 'inconclusive', 'reason_code' => 'file_hash_mismatch', 'engine' => 'ClamAV', + 'engine_version' => 'unknown', 'signatures_at' => null, 'duration_ms' => 0, 'detail' => null, + ], $startedAt, 'inconclusive'); - return; + return; + } + + $result = $scanner->scan($path, (int) ($document->size_bytes ?? filesize($path)), $root); + + $afterHash = $this->fileHash($path); + if ($afterHash === null || ! hash_equals(trim($document->sha256), $afterHash)) { + $result['result'] = 'inconclusive'; + $result['reason_code'] = 'file_changed_during_scan'; + $result['detail'] = null; + } + $result['file_sha256'] = $afterHash; + + $this->record($document, $result, $startedAt, $result['result']); + } finally { + if (is_string($temporary) && is_file($temporary)) { + unlink($temporary); + } } - - $result = $scanner->scan($path, (int) ($document->size_bytes ?? filesize($path)), $root); - - $afterHash = $this->fileHash($path); - if ($afterHash === null || ! hash_equals(trim($document->sha256), $afterHash)) { - $result['result'] = 'inconclusive'; - $result['reason_code'] = 'file_changed_during_scan'; - $result['detail'] = null; - } - $result['file_sha256'] = $afterHash; - - $this->record($document, $result, $startedAt, $result['result']); } public function failed(Throwable $exception): void diff --git a/app/Services/OpportunityResourceInstallation.php b/app/Services/OpportunityResourceInstallation.php new file mode 100644 index 00000000..b51dc37e --- /dev/null +++ b/app/Services/OpportunityResourceInstallation.php @@ -0,0 +1,53 @@ + hash_file('sha256', $file), $files), JSON_THROW_ON_ERROR)); + } + + /** @return array */ + public function preview(): array + { + $path = storage_path('app/private/investmoz-v2/opportunity-resources-activation-proof.json'); + $proof = is_file($path) ? json_decode(file_get_contents($path), true) : []; + $identity = DB::selectOne('SELECT current_database() AS database,inet_server_port() AS port'); + $code = $this->fingerprint(); + $ready = ($proof['restore_verified'] ?? false) && ($proof['compatibility_verified'] ?? false) && ($proof['reversal_verified'] ?? false) && ($proof['qa_passed'] ?? false) && ($proof['code_hash'] ?? '') === $code && ($proof['target']['database'] ?? '') === $identity->database && (int) ($proof['target']['port'] ?? 0) === (int) $identity->port && strtotime($proof['verified_at'] ?? '') >= time() - 86400 && is_file($proof['dump_path'] ?? '') && hash_file('sha256', $proof['dump_path']) === ($proof['sha256'] ?? null); + $installed = app(OpportunityResources::class)->installed(); + + return ['installed' => $installed, 'ready' => $ready, 'target' => $identity, 'code_hash' => $code, 'hash' => hash('sha256', json_encode([$identity, $code, $ready, $installed], JSON_THROW_ON_ERROR)), 'migration' => self::MIGRATION, 'backupDate' => $proof['verified_at'] ?? null, 'changes' => ['Recursos privados de preparação ligados às oportunidades existentes, documents e geo_locations; nenhum dado existente alterado.', 'Originais cifrados; scan pendente bloqueia acesso aos binários, não preparação de metadados/localizações.', 'Versões, exclusão lógica e restauro com auditoria; nenhuma publicação automática.', 'Pendentes: ClamD real, derivadas seguras/revisão/publicação e compatibilidade com limites oficiais onde ausentes.'], 'rollback' => 'Reversão apenas com tabelas vazias e sem documentos cifrados; após utilização, preservar e reconciliar versões antes de uma ação autorizada.']; + } + + public function apply(User $actor, string $hash): void + { + abort_unless($actor->can('manage-portal'), 403); + DB::transaction(function () use ($actor, $hash) { + DB::select('SELECT pg_advisory_xact_lock(91026,100000)'); + $p = $this->preview(); + abort_unless(hash_equals($p['hash'], $hash), 409, 'Plano alterado; reveja.'); + if ($p['installed']) { + return; + } + abort_unless($p['ready'], 422, 'Provas desta versão necessárias.'); + config(['opportunity_resources.authenticated_installation' => true]); + try { + abort_unless(Artisan::call('migrate', ['--path' => 'database/migrations/'.self::MIGRATION.'.php', '--force' => true]) === 0, 500); + } finally { + config(['opportunity_resources.authenticated_installation' => false]); + } + DB::table('audit_events')->insert(['source_system' => 'opportunity-resources-install', 'entity_type' => 'installation', 'action' => 'opportunity_resources_installed', 'actor_user_id' => $actor->id, 'after_data' => json_encode(['code_hash' => $p['code_hash'], 'migration' => self::MIGRATION])]); + }); + } +} diff --git a/app/Services/OpportunityResources.php b/app/Services/OpportunityResources.php new file mode 100644 index 00000000..c48d180b --- /dev/null +++ b/app/Services/OpportunityResources.php @@ -0,0 +1,255 @@ + */ + public function listing(int $opportunityId, string $kind): array + { + $query = DB::table($this->table($kind))->where('opportunity_id', $opportunityId)->orderBy('created_at')->orderBy('id'); + $rows = $query->get()->all(); + foreach ($rows as $row) { + $row->payload = json_decode($row->payload, true, 512, JSON_THROW_ON_ERROR); + if ($kind !== 'locations') { + $row->file = DB::table('documents')->where('id', $row->document_id)->first(['file_name', 'media_type', 'size_bytes', 'sha256', 'malware_scan_status', 'malware_failure_reason', 'malware_engine']); + } + } + + return $rows; + } + + /** @param array $input */ + public function save(User $actor, int $opportunityId, string $kind, array $input, ?UploadedFile $file = null, ?string $id = null): object + { + abort_unless($actor->can('manage-portal'), 403); + abort_unless($this->installed(), 503); + $table = $this->table($kind); + $data = Validator::make($input, [ + 'etag' => 'required|string|size:64', 'version' => ($id ? 'required' : 'nullable').'|integer|min:1', + 'creation_key' => ($id ? 'nullable' : 'required').'|uuid', 'reason' => 'required|string|min:20|max:1000', + 'payload' => 'required|array', + ])->validate(); + $payload = $this->validatePayload($kind, $data['payload'], $opportunityId); + $stored = null; + $documentId = null; + if ($file) { + [$stored, $documentId] = $this->store($kind, $file); + } elseif (! $id && $kind !== 'locations') { + throw ValidationException::withMessages(['file' => 'Selecione o ficheiro original.']); + } + try { + $result = DB::transaction(function () use ($actor, $opportunityId, $kind, $table, $data, $payload, $file, $id, $stored, $documentId) { + $opportunity = $this->lock($opportunityId, $data['etag']); + $record = $id ? DB::table($table)->where('id', $id)->where('opportunity_id', $opportunityId)->lockForUpdate()->first() : null; + abort_if($id && ! $record, 404); + abort_if($record && ($record->deleted_at || (int) $record->version !== (int) $data['version']), 409, 'Recurso alterado ou eliminado; preserve o preenchimento e reveja a versão.'); + $resourceId = $id ?? $data['creation_key']; + abort_if(! $id && DB::table($table)->where('id', $resourceId)->exists(), 409, 'Esta criação já foi registada; recupere o recurso existente.'); + $active = DB::table($table)->where('opportunity_id', $opportunityId)->whereNull('deleted_at'); + abort_if(! $id && $active->count() >= ($kind === 'locations' ? 50 : ($kind === 'images' ? 20 : 100)), 422, 'Limite de recursos atingido.'); + if ($kind === 'locations') { + $others = (clone $active)->when($id, fn ($q) => $q->where('id', '!=', $id))->get(); + foreach ($others as $other) { + $otherPayload = json_decode($other->payload, true); + $keys = ['province_id', 'district_id', 'admin_post_id', 'locality_id', 'role', 'operational_unit']; + abort_if(array_intersect_key($payload, array_flip($keys)) == array_intersect_key($otherPayload, array_flip($keys)), 422, 'Esta localização, papel e unidade já existem.'); + abort_if(($payload['is_primary'] ?? false) && ($otherPayload['is_primary'] ?? false), 422, 'Já existe localização principal; altere a anterior antes de escolher outra.'); + } + } + if ($kind === 'images') { + foreach ((clone $active)->when($id, fn ($q) => $q->where('id', '!=', $id))->get() as $other) { + $p = json_decode($other->payload, true); + abort_if(($payload['is_cover'] ?? false) && ($p['is_cover'] ?? false), 422, 'Já existe uma capa; retire essa seleção antes de escolher outra.'); + abort_if(isset($payload['sort_order'], $p['sort_order']) && $payload['sort_order'] === $p['sort_order'], 422, 'A ordem já está ocupada.'); + } + } + if ($file) { + $hash = hash_file('sha256', Storage::disk('local')->path($stored)); + $duplicate = DB::table($table.' as r')->join('documents as d', 'd.id', '=', 'r.document_id')->where('r.opportunity_id', $opportunityId)->where('d.sha256', $hash)->when($id, fn ($q) => $q->where('r.id', '!=', $id))->exists(); + if ($duplicate) { + throw ValidationException::withMessages(['file' => 'Os mesmos bytes já estão ligados a esta oportunidade; recupere o recurso, sem duplicar.']); + } + $original = Storage::disk('local')->get($stored); + abort_unless(Storage::disk('local')->put($stored, Crypt::encryptString($original)), 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]); + } + $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; + } + if ($record) { + DB::table($table)->where('id', $id)->update($values); + } else { + DB::table($table)->insert([...$values, 'id' => $resourceId, 'opportunity_id' => $opportunityId, 'created_by' => $actor->id, 'created_at' => now()]); + } + $after = DB::table($table)->where('id', $resourceId)->first(); + $this->audit($actor, $kind, $record, $after, $data['reason'], 'saved'); + $opportunity->management_version++; + $opportunity->save(); + if ($documentId) { + ScanDocumentForMalware::dispatch($documentId)->afterCommit(); + } + + return $after; + }); + } catch (\Throwable $error) { + if ($stored) { + Storage::disk('local')->delete($stored); + } + throw $error; + } + + return $result; + } + + /** @param array $input */ + public function lifecycle(User $actor, int $opportunityId, string $kind, string $id, array $input): void + { + abort_unless($actor->can('manage-portal'), 403); + $data = Validator::make($input, ['etag' => 'required|string|size:64', 'version' => 'required|integer|min:1', 'action' => 'required|in:delete,restore', 'reason' => 'required|string|min:20|max:1000', 'confirm' => 'required|accepted'])->validate(); + DB::transaction(function () use ($actor, $opportunityId, $kind, $id, $data) { + $opportunity = $this->lock($opportunityId, $data['etag']); + $table = $this->table($kind); + $record = DB::table($table)->where('id', $id)->where('opportunity_id', $opportunityId)->lockForUpdate()->first(); + abort_unless($record, 404); + abort_if((int) $record->version !== (int) $data['version'], 409, 'Versão alterada.'); + abort_if(($data['action'] === 'delete') === ($record->deleted_at !== null), 409, 'Estado já alterado.'); + $payload = json_decode($record->payload, true); + if ($data['action'] === 'restore') { + $this->validatePayload($kind, $payload, $opportunityId); + // Restoration never silently reclaims a primary/cover/order occupied by another resource. + if ($kind === 'images') { + $payload['is_cover'] = false; + unset($payload['sort_order']); + } + if ($kind === 'locations') { + $payload['is_primary'] = false; + } + } + if ($kind === 'locations' && $data['action'] === 'delete') { + foreach (DB::table('opportunity_images')->where('opportunity_id', $opportunityId)->whereNull('deleted_at')->get() as $image) { + abort_if((json_decode($image->payload, true)['location_id'] ?? null) === $id, 422, 'Uma imagem usa esta localização; corrija a ligação antes de excluir.'); + } + } + DB::table($table)->where('id', $id)->update(['deleted_at' => $data['action'] === 'delete' ? now() : null, 'version' => $record->version + 1, 'payload' => json_encode($payload), 'updated_at' => now()]); + $this->audit($actor, $kind, $record, DB::table($table)->where('id', $id)->first(), $data['reason'], $data['action']); + $opportunity->management_version++; + $opportunity->save(); + }); + } + + private function lock(int $id, string $etag): Opportunity + { + $record = Opportunity::withTrashed()->whereKey($id)->lockForUpdate()->firstOrFail(); + abort_if($record->trashed() || $record->status === 'archived', 409, 'Restaure a oportunidade antes de editar recursos.'); + if (! hash_equals(app(OpportunityManagement::class)->etag($record), $etag)) { + throw new HttpResponseException(response()->json(['message' => 'Conflito de versão.', 'errors' => ['etag' => ['Oportunidade alterada. Preserve os dados e recarregue antes de reconciliar.']]], 409)); + } + + return $record; + } + + /** @param array $payload + * @return array + */ + private function validatePayload(string $kind, array $payload, int $opportunityId): array + { + $rules = ['title' => 'nullable|string|max:120', 'source_reference' => 'nullable|string|max:2000', 'source_date' => 'nullable|date_format:Y-m-d', 'visibility' => 'nullable|in:internal,authenticated,public']; + if ($kind === 'locations') { + $rules += ['province_id' => 'nullable|uuid', 'district_id' => 'nullable|uuid', 'admin_post_id' => 'nullable|uuid', 'locality_id' => 'nullable|uuid', 'role' => 'nullable|in:headquarters,production,raw_material,storage,logistics,market,support,other', 'role_description' => 'nullable|string|max:1000', 'operational_unit' => 'nullable|string|max:120', 'is_primary' => 'nullable|boolean', 'address' => 'nullable|string|max:300', 'geometry' => 'nullable|array', 'area_value' => 'nullable|numeric|gt:0', 'area_unit' => 'nullable|in:sqm,ha,sqkm', 'allocation_percentage' => 'nullable|numeric|between:0,100', 'territorial_purpose' => 'nullable|in:implementation,context,impact,coverage', 'multi_district_rationale' => 'nullable|string|max:1000']; + } elseif ($kind === 'images') { + $rules += ['role' => 'nullable|in:cover,gallery,location,asset,product,plan,rendering,evidence,other', 'role_description' => 'nullable|string|max:1000', 'is_cover' => 'nullable|boolean', 'caption' => 'nullable|string|max:300', 'alt_text' => 'nullable|string|max:250', 'location_id' => ['nullable', 'uuid', Rule::exists('opportunity_location_drafts', 'id')->where('opportunity_id', $opportunityId)->whereNull('deleted_at')], 'captured_at' => 'nullable|date_format:Y-m-d|before_or_equal:today', 'author_source' => 'nullable|string|max:150', 'rights_status' => 'nullable|in:owned,licensed,authorized,public_domain', 'rights_document_id' => ['nullable', 'uuid', Rule::exists('opportunity_documents', 'id')->where('opportunity_id', $opportunityId)->whereNull('deleted_at')], 'rights_valid_until' => 'nullable|date_format:Y-m-d', 'sort_order' => 'nullable|integer|min:0']; + } else { + $rules += ['category' => 'nullable|string|max:100', 'issuer' => 'nullable|string|max:255', 'edition' => 'nullable|string|max:120', 'valid_until' => 'nullable|date_format:Y-m-d', 'classification' => 'nullable|in:public,internal,confidential,restricted', 'section' => 'nullable|string|max:255', 'supported_statement' => 'nullable|string|max:2000']; + } + $data = Validator::make($payload, $rules)->validate(); + if ($kind === 'locations') { + app(InvestmentGeography::class)->selection($data, true); + if (! empty($data['geometry'])) { + $geometry = $data['geometry']; + if (! in_array($geometry['type'] ?? '', ['Point', 'LineString', 'Polygon', 'MultiPolygon'], true) || isset($geometry['crs'])) { + throw ValidationException::withMessages(['geometry' => 'Use GeoJSON Point/LineString/Polygon/MultiPolygon EPSG:4326.']); + } + try { + $check = DB::selectOne('WITH g AS (SELECT ST_SetSRID(ST_GeomFromGeoJSON(?),4326) AS geom) SELECT ST_IsValid(geom) AS valid, ST_IsEmpty(geom) AS empty, ST_XMin(Box3D(geom)) AS xmin,ST_XMax(Box3D(geom)) AS xmax,ST_YMin(Box3D(geom)) AS ymin,ST_YMax(Box3D(geom)) AS ymax FROM g', [json_encode($geometry)]); + if (! $check->valid || $check->empty || $check->xmin < -180 || $check->xmax > 180 || $check->ymin < -90 || $check->ymax > 90) { + throw new \RuntimeException; + } + } catch (\Throwable) { + throw ValidationException::withMessages(['geometry' => 'Geometria inválida, vazia ou fora de EPSG:4326.']); + } + $data['spatial_compatibility'] = 'pending_official_boundary'; + } + } + + return $data; + } + + /** @return array{string,string} */ + private function store(string $kind, UploadedFile $file): array + { + $mimes = $kind === 'images' ? 'image/jpeg,image/png,image/webp' : 'application/pdf,application/vnd.openxmlformats-officedocument.wordprocessingml.document,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,image/png,image/jpeg'; + Validator::make(['file' => $file], ['file' => ['required', 'file', 'mimetypes:'.$mimes, 'max:'.($kind === 'images' ? 10240 : 20480)]])->validate(); + if (str_starts_with((string) $file->getMimeType(), 'image/')) { + $size = @getimagesize($file->getRealPath()); + if (! $size || $size[0] < 400 || $size[1] < 400) { + throw ValidationException::withMessages(['file' => 'Imagem inválida ou inferior a 400 × 400 píxeis.']); + } + $bytes = file_get_contents($file->getRealPath()); + if (preg_match('/<\?(?:php|=)|getMimeType() === 'image/png') || (str_contains($bytes, 'ANIM') && $file->getMimeType() === 'image/webp')) { + throw ValidationException::withMessages(['file' => 'Imagem animada ou com conteúdo executável não permitida.']); + } + } + if (str_contains((string) $file->getMimeType(), 'openxmlformats')) { + $zip = new \ZipArchive; + if ($zip->open($file->getRealPath()) !== true) { + throw ValidationException::withMessages(['file' => 'Pacote Office inválido.']); + } + $required = str_contains($file->getMimeType(), 'wordprocessingml') ? 'word/document.xml' : 'xl/workbook.xml'; + $valid = $zip->locateName($required) !== false && $zip->locateName('[Content_Types].xml') !== false; + $zip->close(); + if (! $valid) { + throw ValidationException::withMessages(['file' => 'Conteúdo Office não corresponde ao formato indicado.']); + } + } + $id = (string) Str::uuid(); + $key = $file->storeAs('opportunity-resources', $id, 'local'); + abort_unless(is_string($key), 500, 'Falha ao guardar original privado.'); + + return [$key, $id]; + } + + private function audit(User $actor, string $kind, ?object $before, object $after, string $reason, string $action): void + { + DB::table('audit_events')->insert(['source_system' => 'opportunity-resources', 'entity_type' => $kind, 'actor_user_id' => $actor->id, 'action' => $action, 'data_classification' => 'restricted', 'before_data' => json_encode(['snapshot' => Crypt::encryptString(json_encode($before))]), 'after_data' => json_encode(['opportunity_id' => $after->opportunity_id, 'resource_id' => $after->id, 'version' => $after->version, 'reason' => Crypt::encryptString($reason), 'snapshot' => Crypt::encryptString(json_encode($after))])]); + } +} diff --git a/database/migrations/2026_10_09_030000_extend_opportunity_resources.php b/database/migrations/2026_10_09_030000_extend_opportunity_resources.php new file mode 100644 index 00000000..86429b0f --- /dev/null +++ b/database/migrations/2026_10_09_030000_extend_opportunity_resources.php @@ -0,0 +1,64 @@ +environment('testing') && str_ends_with($identity->database, '_test') && (int) $identity->port === 55441) && ! config('opportunity_resources.authenticated_installation')) { + throw new RuntimeException('QA isolada ou instalação autenticada necessária.'); + } + // Metadata belongs to the existing opportunity. Binaries and territories stay in their existing repositories. + Schema::table('documents', function (Blueprint $t) { + $t->string('storage_encryption', 30)->nullable(); + }); + foreach (['documents', 'images', 'location_drafts'] as $kind) { + Schema::create('opportunity_'.$kind, function (Blueprint $t) use ($kind) { + $t->uuid('id')->primary(); + $t->foreignId('opportunity_id')->constrained('opportunities')->restrictOnDelete(); + if ($kind !== 'location_drafts') { + $t->foreignUuid('document_id')->constrained('documents')->restrictOnDelete(); + } + $t->jsonb('payload'); + $t->unsignedInteger('version')->default(1); + $t->foreignId('created_by')->constrained('users')->restrictOnDelete(); + $t->timestampsTz(); + $t->softDeletesTz(); + $t->index(['opportunity_id', 'deleted_at']); + }); + } + DB::unprepared(<<<'SQL' +CREATE OR REPLACE FUNCTION guard_opportunity_resource_delete() RETURNS trigger LANGUAGE plpgsql AS $$ +BEGIN + RAISE EXCEPTION 'Preservar recursos, versões e histórico; usar exclusão lógica'; +END $$; +CREATE TRIGGER protect_opportunity_documents BEFORE DELETE ON opportunity_documents FOR EACH ROW EXECUTE FUNCTION guard_opportunity_resource_delete(); +CREATE TRIGGER protect_opportunity_images BEFORE DELETE ON opportunity_images FOR EACH ROW EXECUTE FUNCTION guard_opportunity_resource_delete(); +CREATE TRIGGER protect_opportunity_location_drafts BEFORE DELETE ON opportunity_location_drafts FOR EACH ROW EXECUTE FUNCTION guard_opportunity_resource_delete(); +SQL); + } + + public function down(): void + { + foreach (['documents', 'images', 'location_drafts'] as $kind) { + if (DB::table('opportunity_'.$kind)->exists()) { + throw new RuntimeException('Preservar recursos antes de reversão.'); + } + } + foreach (['documents', 'images', 'location_drafts'] as $kind) { + Schema::drop('opportunity_'.$kind); + } + DB::statement('DROP FUNCTION guard_opportunity_resource_delete()'); + if (DB::table('documents')->whereNotNull('storage_encryption')->exists()) { + throw new RuntimeException('Preservar originais cifrados antes de reversão.'); + } + Schema::table('documents', function (Blueprint $t) { + $t->dropColumn('storage_encryption'); + }); + } +}; diff --git a/docs/investmoz/v2/backlog-mestre.md b/docs/investmoz/v2/backlog-mestre.md index fe3cc4ca..bb41212a 100644 --- a/docs/investmoz/v2/backlog-mestre.md +++ b/docs/investmoz/v2/backlog-mestre.md @@ -217,3 +217,7 @@ Todos mantêm estado editorial `published` e inclusão no filtro público do cat **Evidência:** 72 testes/1 164 asserções, TypeScript/PHPStan/build e percurso desktop/móvel passaram. Backup/restauro de 288 tabelas/cinco ficheiros e reversão antes de uso comprovados em `desk_restore_20261009_060826_test:55441`. Plano único `/management/opportunity-management-install`, válido até 10/10/2026 06:08:36 UTC; aplicação exclusivamente autenticada. Leitura ativa em 09/10/2026 06:13:34 UTC: migration ausente, zero eventos desta gestão e seis classificações reais mantidas. **Preparado, não aplicado na base ativa.** **Pendentes da especificação:** catálogos completos/dropdowns pesquisáveis, maturidade de oito etapas, wizard/autosave, imagens/documentos/multilocalização completa, validação final, segregação de papéis, revisão/submissão/aprovação/publicação e perfis especializados de investimento/imobiliário/direitos fundiários/transações. Matriz por secção no relatório; CRUD não certifica REQ01–REQ17/DT01–DT15 integrais. Cobertura do inventário, questões jurídicas, ClamD real e primeiro pedido real continuam separados. + +### Gestão de Oportunidades — confirmação da instalação ativa + +Leitura em 09/10/2026 06:34:48 UTC: migration aplicada uma vez; evento `opportunity_management_installed` em 06:29:42 UTC, conta 13. Seis IDs classificados real, dados originais e relações iguais ao backup, 124 eventos históricos intactos; apenas um evento de instalação novo. [Confirmação detalhada](gestao-oportunidades-dashboard.md#confirmação-ativa--9-de-outubro-063448-utc). Nenhuma reinstalação ou escrita ativa pelo Codex. Imagens/documentos/multilocalização passam ao próximo incremento QA; workflow final e módulos especializados continuam pendentes. diff --git a/docs/investmoz/v2/gestao-oportunidades-dashboard.md b/docs/investmoz/v2/gestao-oportunidades-dashboard.md index 14679bc4..429b675c 100644 --- a/docs/investmoz/v2/gestao-oportunidades-dashboard.md +++ b/docs/investmoz/v2/gestao-oportunidades-dashboard.md @@ -54,3 +54,9 @@ Backup verificado em **09/10/2026 06:08:36 UTC**, válido para esta pré-visuali Reversão direta `down` apenas antes de novos eventos ou drafts incompletos. Depois de uso, preservar/reconciliar dados e preparar reversão autorizada. Restauro integral exige janela e reconciliação de escritas posteriores; ensaio em cópia não autoriza sobrescrever a base ativa. Pendências separadas: cobertura do inventário, questões jurídicas específicas, ClamD real, referências institucionais concretas e primeiro pedido real no Balcão. Nenhuma foi concluída por estes testes. + +## Confirmação ativa — 9 de outubro, 06:34:48 UTC + +Leitura transacional `READ ONLY` confirma a migration aplicada uma vez. Evento `opportunity_management_installed`, ID `08c433b9-ba6d-4270-9034-78ec5b69e2f9`, conta 13, em 06:29:42 UTC, hash `1ae520dcb5427e0dbfb3fcbfbb75b188acb949a54248a39fc38b71241ca54f26`. Nenhuma reinstalação executada. + +Os seis IDs continuam classificados real. Comparação do conteúdo original das oportunidades (excluindo apenas colunas acrescentadas pela migration) e dez conjuntos relacionados confirma hashes iguais ao backup: seis registos, 28 localizações de projetos, 13 relações cluster–projeto, quatro pedidos/recibos, uma mensagem e 124 eventos anteriores preservados. Zero novas relações oportunidade–projeto/cluster. Apenas o evento de instalação foi acrescentado. Evidência privada `opportunity-management-active-read-only.json`; leitor versionado `tools/opportunity-management-active-preview.php`. O estado atual da primeira entrega é **aplicado pelo utilizador e confirmado por leitura**; as lacunas funcionais mantêm-se. diff --git a/resources/js/pages/management/opportunity-draft.tsx b/resources/js/pages/management/opportunity-draft.tsx index ae4907c3..64745752 100644 --- a/resources/js/pages/management/opportunity-draft.tsx +++ b/resources/js/pages/management/opportunity-draft.tsx @@ -1,10 +1,10 @@ -import { Link, router } from '@inertiajs/react'; -import { useState, useEffect } from 'react'; +import { Link, router } from "@inertiajs/react"; +import { useState, useEffect } from "react"; import { TerritoryLevel, type TerritoryOption, -} from '@/components/investment-territory'; -import { Management, Field, button, control } from './shared'; +} from "@/components/investment-territory"; +import { Management, Field, button, control } from "./shared"; type Values = Record; type Entry = { id: number; @@ -47,43 +47,43 @@ export default function OpportunityDraft({ }); const [busy, setBusy] = useState(false), [errors, setErrors] = useState>({}), - [message, setMessage] = useState(''), - [reason, setReason] = useState(''), + [message, setMessage] = useState(""), + [reason, setReason] = useState(""), [confirm, setConfirm] = useState(false), [token, setToken] = useState(etag); useEffect(() => setToken(etag), [etag]); const [path, setPath] = useState(territoryPath); const unavailable = Boolean( - record?.deleted_at || record?.status === 'archived', + record?.deleted_at || record?.status === "archived", ); const set = (key: string, value: string) => setData((d) => ({ ...d, [key]: value })); - async function send(action: 'save' | 'delete' | 'restore') { + async function send(action: "save" | "delete" | "restore") { if (busy) return; setBusy(true); setErrors({}); - setMessage(''); + setMessage(""); try { const cookie = document.cookie - .split('; ') - .find((c) => c.startsWith('XSRF-TOKEN=')); + .split("; ") + .find((c) => c.startsWith("XSRF-TOKEN=")); const url = - action === 'save' + action === "save" ? record ? `/management/opportunities/${record.id}/draft` - : '/management/opportunities/drafts' + : "/management/opportunities/drafts" : `/management/opportunities/${record!.id}/lifecycle`; const response = await fetch(url, { - method: action === 'save' && record ? 'PUT' : 'POST', + method: action === "save" && record ? "PUT" : "POST", headers: { - Accept: 'application/json', - 'Content-Type': 'application/json', - 'X-XSRF-TOKEN': cookie + Accept: "application/json", + "Content-Type": "application/json", + "X-XSRF-TOKEN": cookie ? decodeURIComponent(cookie.slice(11)) - : '', + : "", }, body: JSON.stringify( - action === 'save' + action === "save" ? { ...data, etag: token, reason } : { etag: token, action, reason, confirm }, ), @@ -94,13 +94,13 @@ export default function OpportunityDraft({ Object.fromEntries( Object.entries(result.errors ?? {}).map(([k, v]) => [ k, - Array.isArray(v) ? v.join(' ') : String(v), + Array.isArray(v) ? v.join(" ") : String(v), ]), ), ); setMessage( result.message ?? - 'Operação falhou; os dados preenchidos foram preservados.', + "Operação falhou; os dados preenchidos foram preservados.", ); return; } @@ -108,15 +108,15 @@ export default function OpportunityDraft({ if (result.etag) setToken(result.etag); if (!record) router.visit(`/management/opportunities/${result.id}/edit`); - else if (action !== 'save') router.reload(); + else if (action !== "save") router.reload(); } catch { setMessage( - 'Resposta indisponível. Os dados foram preservados; confira o registo no servidor antes de repetir.', + "Resposta indisponível. Os dados foram preservados; confira o registo no servidor antes de repetir.", ); } finally { setBusy(false); requestAnimationFrame(() => - document.getElementById('opportunity-result')?.focus(), + document.getElementById("opportunity-result")?.focus(), ); } } @@ -124,8 +124,8 @@ export default function OpportunityDraft({ Voltar à gestão @@ -143,13 +143,13 @@ export default function OpportunityDraft({
{message} {errors.etag && (

- {errors.etag}{' '} + {errors.etag}{" "} { e.preventDefault(); - void send('save'); + void send("save"); }} >

{( [ - 'province_id', - 'district_id', - 'admin_post_id', - 'locality_id', + "province_id", + "district_id", + "admin_post_id", + "locality_id", ] as const ).map((key, index) => ( ({ ...previous, - province_id: next[0]?.id ?? '', - district_id: next[1]?.id ?? '', - admin_post_id: next[2]?.id ?? '', - locality_id: next[3]?.id ?? '', + province_id: next[0]?.id ?? "", + district_id: next[1]?.id ?? "", + admin_post_id: next[2]?.id ?? "", + locality_id: next[3]?.id ?? "", })); }} /> @@ -215,9 +215,9 @@ export default function OpportunityDraft({ > {Object.entries({ - title: 'Título oficial', - summary: 'Resumo', - description: 'Descrição', - promoter_name: 'Promotor', + title: "Título oficial", + summary: "Resumo", + description: "Descrição", + promoter_name: "Promotor", }).map(([key, label]) => ( - {key === 'description' || key === 'summary' ? ( + {key === "description" || key === "summary" ? (