> */ private function rows(mixed $value): array { if (! is_array($value)) { throw new \LogicException('Dossier list is not recoverable; original retained.'); } $rows = []; foreach ($value as $row) { if (! is_array($row)) { throw new \LogicException('Dossier row is not recoverable; original retained.'); } $rows[] = $row; } return $rows; } /** @param array $data */ private function hash(array $data): string { return hash('sha256', json_encode(Arr::sortRecursive($data), JSON_THROW_ON_ERROR)); } private function events(string $id, ?string $opportunity): Builder { return DB::table('audit_events')->where('source_system', self::SOURCE)->where('entity_type', 'desk_cases')->where('entity_id', $id) ->where('after_data->scope', $opportunity ?? 'dossier'); } /** @return array */ private function decode(?\stdClass $event): array { return $event ? json_decode(Crypt::decryptString(json_decode($event->after_data, true, 512, JSON_THROW_ON_ERROR)['snapshot']), true, 512, JSON_THROW_ON_ERROR) : ['version' => 0, 'apiex' => ['additional_documents' => [], 'note' => '', 'review' => null, 'exports' => [], 'external' => []], 'swot' => ['object_key' => '', 'scope' => '', 'rows' => [], 'source_fingerprint' => '', 'review' => null, 'actions' => []]]; } /** @return array */ private function current(string $id, ?string $opportunity): array { return $this->decode($this->events($id, $opportunity)->orderByRaw("(after_data->>'version')::integer DESC")->first()); } /** @return array{0: \stdClass, 1: array, 2: ?string, 3: array, 4: bool} */ private function context(User $actor, string $id, ?string $opportunity, bool $initialScope = false): array { abort_unless(app(InvestorPath::class)->available() && app(PrivateInvestmentPreparation::class)->available(), 404); $case = $this->desk->case($actor, $id); abort_unless($this->desk->owns($actor, $case, false) || $actor->can('manage-portal'), 403); $context = app(InvestorContext::class)->state($case); $scope = app(InvestmentConsumerScope::class); $historical = false; abort_if($initialScope && ! $this->events($id, null)->exists(), 404); $choices = array_column(array_column($context['opportunity_choices'] ?? [], 'opportunity'), 'key'); if ((($opportunity && ! in_array($opportunity, $choices, true)) || ($initialScope && $choices)) && $this->events($id, $opportunity)->exists()) { $saved = $this->current($id, $opportunity); $last = $saved['apiex']['exports'] ? $saved['apiex']['exports'][array_key_last($saved['apiex']['exports'])] : null; $oldContext = $saved['context_snapshot'] ?? $last['prepared']['context'] ?? $saved['swot']['sources_snapshot']['context'] ?? null; abort_unless(is_array($oldContext), 409, 'Contexto histórico conservado; snapshot de consulta indisponível.'); $context = $oldContext; $historical = true; } else { $opportunity = $scope->key($context, $opportunity, true); $context = $scope->context($context, $opportunity); } $documents = app(InvestorContext::class)->preparationDocuments($id); // Reuse request-level document authorization, including for a dossier team member. $documents = array_values(array_filter($documents, function ($document) use ($actor, $id) { $link = DB::table('desk_documents')->where('id', $document['id'])->where('case_id', $id)->first(); if ($link?->request_id) { try { $this->desk->request($actor, $link->request_id); } catch (HttpException $e) { if (in_array($e->getStatusCode(), [403, 404], true)) { return false; } throw $e; } } return true; })); foreach ($documents as &$document) { $record = DB::table('documents')->where('id', $document['document_id'])->first(); $document['available'] = $record && $record->status === 'active' && $record->malware_scan_status === 'clean' && hash_equals(trim($record->sha256), trim((string) $record->malware_scanned_sha256)); } unset($document); $unique = []; foreach ($documents as $document) { $unique[$document['document_id']] = $document; } return [$case, $context, $opportunity, array_values($unique), $historical]; } /** @param array $context * @return array */ private function sources(User $actor, \stdClass $case, array $context, string $object): array { $objects = [['key' => 'investment:'.$case->id, 'label' => $case->title, 'kind' => 'investment']]; $evidence = []; $refs = array_values($context['selection'] ?? []); foreach ($context['opportunity_choices'] ?? [] as $choice) { $refs[] = $choice['opportunity']; } foreach ($refs as $ref) { if (! is_array($ref) || empty($ref['key'])) { continue; } try { $live = app(InvestorContext::class)->reference($ref['key']); } catch (ModelNotFoundException|HttpException $e) { $live = ['key' => $ref['key'], 'unavailable' => true]; } $evidence[$ref['key']] = ['key' => $ref['key'], 'label' => $ref['title'] ?? $ref['key'], 'consulted' => $ref, 'current' => $live]; if (in_array($ref['kind'] ?? '', ['territory', 'sector'], true)) { $objects[] = ['key' => $ref['key'], 'label' => $ref['title'], 'kind' => $ref['kind']]; } if (($ref['kind'] ?? '') === 'portfolio') { $portfolio = InvestmentPortfolioController::visible()->whereKey($ref['id'])->first(); if ($portfolio?->cluster_id) { $cluster = EconomicCluster::find($portfolio->cluster_id); if ($cluster) { $objects[] = ['key' => 'cluster:'.$cluster->id, 'label' => $cluster->getAttribute('name'), 'kind' => 'cluster']; } } } } // Sector/territory may have been deliberately entered rather than selected as a source. foreach ([['sector', 'sector_id'], ['territory', 'province_id'], ['territory', 'district_id'], ['territory', 'admin_post_id'], ['territory', 'locality_id']] as [$kind, $field]) { if (! empty($context['fields'][$field])) { $key = $kind.':'.$context['fields'][$field]; try { $ref = app(InvestorContext::class)->reference($key); $objects[] = ['key' => $key, 'label' => $ref['title'], 'kind' => $kind]; $evidence[$key] = ['key' => $key, 'label' => $ref['title'], 'consulted' => $ref, 'current' => $ref]; } catch (ModelNotFoundException|HttpException $e) { /* Keep the declared context; do not invent evidence. */ } } } // A manually chosen cluster must already be a public, visible portfolio association. if (str_starts_with($object, 'cluster:')) { $clusterId = substr($object, 8); $visible = InvestmentPortfolioController::visible()->where('cluster_id', $clusterId)->exists(); $cluster = $visible ? EconomicCluster::find($clusterId) : null; $objects[] = ['key' => $object, 'label' => $cluster ? $cluster->getAttribute('name') : 'Cluster da análise guardada — fonte actual indisponível', 'kind' => 'cluster']; } foreach ($objects as $item) { if ($item['kind'] === 'cluster') { $evidence[$item['key']] = ['key' => $item['key'], 'label' => 'Competitividade — '.$item['label'], 'current' => InvestmentPortfolioController::visible()->where('cluster_id', substr($item['key'], 8))->exists() ? app(ClusterCompetitiveness::class)->publicData(substr($item['key'], 8)) : null, 'notice' => 'Versão pública aprovada; ausência de análise não prova ausência de oferta/concorrência.']; } } $unique = []; foreach ($objects as $item) { $unique[$item['key']] = $item; } return [array_values($unique), array_values($evidence)]; } /** @return array */ public function view(User $actor, string $id, ?string $opportunity = null, ?int $historyBefore = null, bool $initialScope = false): array { [$case, $context, $opportunity, $documents, $historical] = $this->context($actor, $id, $opportunity, $initialScope); $state = $this->current($id, $opportunity); [$objects, $sources] = $this->sources($actor, $case, $context, $state['swot']['object_key']); $payload = $this->desk->decode($case->payload); $reused = ['investor' => Arr::only($payload, ['name', 'email']), 'profile' => Arr::only($payload['profile'] ?? [], ['phone', 'nuit', 'address', 'country_code', 'nationality', 'residence_country_code']), 'company' => Arr::only($payload['company'] ?? [], ['id', 'name', 'nuit', 'registration', 'address', 'type']), 'company_version' => $payload['company_version'] ?? null, 'notice' => 'Valores autorizados conservados no dossier; não foram substituídos por dados recentes de outra origem.']; if ($historical && isset($state['reused_snapshot'])) { $reused = $state['reused_snapshot']; } foreach ($documents as $document) { $sources[] = ['key' => 'document:'.$document['document_id'], 'label' => $document['file_name'], 'current' => $document, 'notice' => 'Documento privado do dossier; disponibilidade não certifica o conteúdo.']; } $assessment = app(PrivateInvestmentPreparation::class)->assess($context, $documents); $issues = $assessment['missing_labels']; if (! $assessment['enabled'] || ! $assessment['period_covered'] || ! $assessment['scope_covered'] || $assessment['mode'] === 'indeterminate') { $issues[] = 'Completar o enquadramento da preparação existente.'; } foreach ($assessment['requirements'] as &$requirement) { $document = collect($this->rows($documents))->firstWhere('document_id', $requirement['document_id']); $requirement['available'] = $document['available'] ?? false; if ($requirement['state'] === 'indeterminate' || ($requirement['required'] && ! $requirement['available'])) { $issues[] = $requirement['label']; } } unset($requirement); $selected = array_values(array_unique([...collect($this->rows($assessment['requirements']))->where('required', true)->pluck('document_id')->filter()->all(), ...$state['apiex']['additional_documents']])); foreach ($selected as $doc) { if (! (collect($this->rows($documents))->firstWhere('document_id', $doc)['available'] ?? false)) { $issues[] = 'Anexo indisponível: '.$doc; } } $documentary = array_values(array_filter($documents, fn ($d) => in_array($d['document_id'], $selected, true))); $fingerprint = $this->hash(['context' => $context, 'reused' => $reused, 'assessment' => $assessment, 'documents' => $documentary, 'note' => $state['apiex']['note']]); $swotFingerprint = $this->hash($this->sourceSnapshot($context, $reused, $sources, $state['swot']['object_key'], $state['swot']['rows'])); $external = collect($this->rows($state['apiex']['external'])); $apiState = $external->contains('kind', 'decision') ? 'decision_received' : ($external->contains('kind', 'delivery') ? 'delivery_recorded' : (! $issues && ($state['apiex']['review']['fingerprint'] ?? null) === $fingerprint ? 'ready' : 'preparing')); $query = $opportunity ? '?opportunity='.urlencode($opportunity) : ''; $editQuery = $historical ? '' : $query; $savedScopes = DB::table('audit_events')->where('source_system', self::SOURCE)->where('entity_type', 'desk_cases')->where('entity_id', $id)->selectRaw("DISTINCT after_data->>'scope' as scope")->get()->map(fn ($row) => ['key' => $row->scope, 'label' => $row->scope === 'dossier' ? 'Contexto inicial do dossier' : $row->scope, 'href' => '/investimentos/'.$id.'/preparacao?'.($row->scope === 'dossier' ? 'history_scope=dossier' : 'opportunity='.urlencode($row->scope))])->all(); $historyRows = $this->events($id, $opportunity)->when($historyBefore !== null, fn ($q) => $q->whereRaw("(after_data->>'version')::integer < ?", [$historyBefore]))->orderByRaw("(after_data->>'version')::integer DESC")->limit(26)->get(); $history = $historyRows->take(25)->map(fn ($e) => ['version' => json_decode($e->after_data, true)['version'], 'action' => $e->action, 'at' => $e->occurred_at, 'actor' => $e->actor_user_id, 'operation_key' => $e->correlation_id])->values()->all(); return ['case' => ['id' => $id, 'title' => $case->title, 'closed' => $case->closed_at !== null], 'opportunity' => $opportunity, 'opportunity_label' => $context['selection']['opportunity']['title'] ?? null, 'historical' => $historical, 'initial_scope' => $initialScope, 'saved_scopes' => $savedScopes, 'version' => $state['version'], 'apiex' => $state['apiex'], 'swot' => $state['swot'], 'assessment' => $assessment, 'context' => $context, 'reused' => $reused, 'documents' => $documents, 'selected_documents' => $documentary, 'api_issues' => array_values(array_unique($issues)), 'api_state' => $apiState, 'api_fingerprint' => $fingerprint, 'swot_fingerprint' => $swotFingerprint, 'swot_review_required' => $state['swot']['source_fingerprint'] !== $swotFingerprint || ($state['swot']['review']['fingerprint'] ?? null) !== $swotFingerprint, 'objects' => $objects, 'sources' => $sources, 'can_write' => ! $historical && ! $case->closed_at && $this->desk->owns($actor, $case), 'can_review' => ! $historical && ! $case->closed_at && ($this->desk->owns($actor, $case) || $actor->can('manage-portal')), 'links' => ['dossier' => '/investimentos/'.$id.$editQuery, 'context' => '/investimentos/'.$id.'/contexto/editar'.$editQuery, 'documents' => '/balcao-virtual/dossiers/'.$id.$query, 'partners' => '/parcerias/painel?context='.$id.($opportunity ? '&opportunity='.urlencode($opportunity) : ''), 'library' => '/documentos?context='.$id.($opportunity ? '&opportunity='.urlencode($opportunity) : ''), 'editorial' => '/acesso-gestao', 'idea' => '/investimentos/'.$id.'/contexto/editar'.$query.'#project-summary', 'opportunity' => '/investimentos/'.$id.'/contexto/editar'.$query.'#project-summary', 'risk' => '/investimentos/'.$id.$query.'#tarefas'], 'history' => $history, 'history_next' => $historyRows->count() > 25 ? ($history[count($history) - 1]['version'] ?? null) : null]; } /** @param array $context * @param array $reused * @param array $sources * @param array $rows * @return array */ private function sourceSnapshot(array $context, array $reused, array $sources, string $object, array $rows): array { $used = array_column($rows, 'source_key'); $tracked = array_values(array_filter($sources, fn ($source) => ! str_starts_with($source['key'], 'document:') || in_array($source['key'], $used, true))); return ['context' => $context, 'reused' => $reused, 'sources' => $tracked, 'object' => $object]; } /** @return array|null */ public function continuation(User $actor, string $id, ?string $opportunity, mixed $actionId): ?array { if (! is_string($actionId) || ! Str::isUuid($actionId)) { return null; } [, , $scope] = $this->context($actor, $id, $opportunity); return collect($this->rows($this->current($id, $scope)['swot']['actions']))->firstWhere('id', $actionId); } /** @return list */ public function taskKeys(User $actor, string $id, string $opportunity): array { [, , $scope] = $this->context($actor, $id, $opportunity); return array_values(array_map(fn ($action) => 'swot:'.$action['id'], $this->current($id, $scope)['swot']['actions'])); } /** @param array $input * @param array $view * @return array */ private function draft(array $input, array $view): array { $data = Validator::make($input, ['object_key' => ['required', Rule::in(array_column($view['objects'], 'key'))], 'scope' => 'present|nullable|string|max:4000', 'source_fingerprint' => 'present|string|size:64', 'rows' => 'present|array|list|max:80', 'rows.*' => 'array:id,dimension,text,reference,source_key,date,classification', 'rows.*.id' => 'required|uuid|distinct', 'rows.*.dimension' => 'required|in:strength,weakness,opportunity,threat', 'rows.*.classification' => 'required|in:documented_fact,hypothesis,unconfirmed', 'rows.*.text' => 'present|nullable|string|max:4000', 'rows.*.reference' => 'present|nullable|string|max:4000', 'rows.*.source_key' => ['nullable', Rule::in(array_column($view['sources'], 'key'))], 'rows.*.date' => 'nullable|date_format:Y-m-d'])->validate(); $snapshot = $this->sourceSnapshot($view['context'], $view['reused'], $view['sources'], $data['object_key'], $data['rows']); return [...$data, 'sources_snapshot' => $snapshot, 'snapshot_fingerprint' => $this->hash($snapshot), 'review' => null, 'actions' => $view['swot']['actions']]; } /** @param array $input * @return array */ public function change(User $actor, string $id, array $input): array { $control = Validator::make($input, ['opportunity' => 'nullable|string|max:150', 'version' => 'required|integer|min:0', 'operation_key' => 'required|uuid', 'action' => 'required|in:apiex_save,apiex_review,apiex_export,external,swot_save,swot_review,swot_action,swot_restore', 'reason' => 'required|string|min:3|max:4000'])->validate(); return DB::transaction(function () use ($actor, $id, $input, $control) { $case = $this->desk->case($actor, $id, false, true); $this->desk->assertOpen($case); $view = $this->view($actor, $id, $control['opportunity'] ?? null); $review = in_array($control['action'], ['apiex_review', 'swot_review'], true); abort_unless($review ? $view['can_review'] : $view['can_write'], 403); $requestHash = $this->hash([...$input, 'actor' => $actor->id]); $prior = $this->events($id, $view['opportunity'])->where('correlation_id', $control['operation_key'])->first(); if ($prior) { abort_unless(hash_equals(json_decode($prior->after_data, true)['request_hash'], $requestHash), 409, 'A tentativa já tem outro conteúdo.'); return [...$this->view($actor, $id, $view['opportunity']), 'replayed' => true]; } abort_unless((int) $control['version'] === $view['version'], 409, 'Existe outra versão. Os seus campos foram preservados; consulte e compare antes de substituir.'); $state = Arr::only($view, ['version', 'apiex', 'swot']); $receipt = ['actor' => $actor->id, 'at' => now()->toISOString(), 'reason' => $control['reason']]; switch ($control['action']) { case 'apiex_save': $data = Validator::make($input, ['note' => 'present|nullable|string|max:6000', 'additional_documents' => 'present|array|list|max:60', 'additional_documents.*' => ['required', 'distinct', Rule::in(array_column($view['documents'], 'document_id'))]])->validate(); $state['apiex'] = [...$state['apiex'], ...$data, 'review' => null]; break; case 'apiex_review': abort_if($view['api_issues'], 422, 'Complete os requisitos e documentos aplicáveis.'); abort_unless(($input['fingerprint'] ?? '') === $view['api_fingerprint'], 409, 'O dossier/documentos mudaram; reveja a versão actual.'); foreach ($view['selected_documents'] as $document) { $record = DB::table('documents')->where('id', $document['document_id'])->whereNull('deleted_at')->lockForUpdate()->first(); abort_unless($record !== null, 404); app(VerifiedDocumentBinary::class)->respond($record); } $state['apiex']['review'] = [...$receipt, 'fingerprint' => $view['api_fingerprint']]; break; case 'apiex_export': abort_if($view['api_issues'] || ($state['apiex']['review']['fingerprint'] ?? '') !== $view['api_fingerprint'], 409, 'Reveja a preparação actual antes de exportar.'); $prepared = Arr::only($view, ['case', 'opportunity', 'context', 'reused', 'assessment', 'selected_documents']); $prepared['apiex'] = Arr::only($state['apiex'], ['note', 'review']); $state['apiex']['exports'][] = [...$receipt, 'id' => $control['operation_key'], 'fingerprint' => $view['api_fingerprint'], 'context_revision' => $view['context']['revision'], 'documents' => $view['selected_documents'], 'prepared' => $prepared]; break; case 'external': $data = Validator::make($input, ['kind' => 'required|in:delivery,clarification,response,decision', 'date' => 'required|date_format:Y-m-d|before_or_equal:today', 'reference' => 'required|string|max:2000', 'proof_document' => ['required', Rule::in(collect($this->rows($view['documents']))->where('available', true)->pluck('document_id')->all())], 'export_id' => 'required|uuid', 'related_id' => 'nullable|uuid', 'outcome' => 'nullable|in:approved,refused,conditional,other'])->validate(); abort_unless(collect($this->rows($state['apiex']['exports']))->contains('id', $data['export_id']), 422, 'Escolha a versão exportada deste dossier/oportunidade.'); $same = collect($this->rows($state['apiex']['external']))->where('export_id', $data['export_id']); if ($data['kind'] !== 'delivery') { abort_unless($same->contains('kind', 'delivery'), 422, 'Registe a entrega externa desta versão.'); } if ($data['kind'] === 'response') { abort_unless($same->where('kind', 'clarification')->contains('id', $data['related_id'] ?? ''), 422, 'Identifique o pedido de esclarecimento a que responde.'); } if ($data['kind'] === 'decision') { abort_unless(! empty($data['outcome']), 422, 'Identifique a decisão efectivamente recebida.'); } $record = DB::table('documents')->where('id', $data['proof_document'])->whereNull('deleted_at')->lockForUpdate()->first(); abort_unless($record !== null, 404); app(VerifiedDocumentBinary::class)->respond($record); $state['apiex']['external'][] = [...$data, ...$receipt, 'id' => $control['operation_key'], 'proof_snapshot' => collect($this->rows($view['documents']))->firstWhere('document_id', $data['proof_document'])]; break; case 'swot_save': $state['swot'] = $this->draft($input['draft'] ?? [], $view); break; case 'swot_review': $swot = $state['swot']; abort_unless(($input['fingerprint'] ?? '') === $view['swot_fingerprint'], 409, 'As fontes mudaram; compare antes de rever.'); abort_unless(trim($swot['scope'] ?? '') !== '' && in_array($swot['object_key'], array_column($view['objects'], 'key'), true), 422, 'Identifique objecto e âmbito.'); foreach (['strength', 'weakness', 'opportunity', 'threat'] as $dimension) { abort_unless(collect($this->rows($swot['rows']))->contains('dimension', $dimension), 422, 'Trate as quatro dimensões; incerteza deve ficar classificada.'); } foreach ($swot['rows'] as $row) { abort_unless(trim($row['text'] ?? '') !== '' && trim($row['reference'] ?? '') !== '' && ! empty($row['date']), 422, 'Cada conclusão exige fonte, data e classificação; desconhecido não é ausência.'); } $state['swot']['source_fingerprint'] = $view['swot_fingerprint']; $state['swot']['sources_snapshot'] = $this->sourceSnapshot($view['context'], $view['reused'], $view['sources'], $state['swot']['object_key'], $state['swot']['rows']); $state['swot']['snapshot_fingerprint'] = $view['swot_fingerprint']; $state['swot']['review'] = [...$receipt, 'fingerprint' => $view['swot_fingerprint'], 'version' => $state['version'] + 1]; break; case 'swot_action': abort_if($view['swot_review_required'], 409, 'Reveja a análise e as fontes antes de ligar uma acção.'); $data = Validator::make($input, ['conclusion_id' => ['required', Rule::in(array_column($state['swot']['rows'], 'id'))], 'kind' => 'required|in:idea,opportunity,partners,risk', 'title' => 'required|string|min:3|max:200'])->validate(); $href = $view['links'][$data['kind']]; if ($data['kind'] === 'risk') { $href = str_replace('#tarefas', (str_contains($href, '?') ? '&' : '?').'swot_action='.$control['operation_key'].'#tarefas', $href); } $state['swot']['actions'][] = [...$data, ...$receipt, 'id' => $control['operation_key'], 'case_id' => $id, 'opportunity' => $view['opportunity'], 'review_version' => $state['swot']['review']['version'], 'href' => $href]; break; case 'swot_restore': $data = Validator::make($input, ['revision' => 'required|integer|min:1'])->validate(); $event = $this->events($id, $view['opportunity'])->where('after_data->version', $data['revision'])->first(); abort_unless($event !== null, 404); $old = $this->decode($event)['swot']; $state['swot'] = [...$old, 'review' => null, 'actions' => $state['swot']['actions']]; break; } $state['context_snapshot'] = $view['context']; $state['reused_snapshot'] = $view['reused']; $state['version']++; DB::table('audit_events')->insert(['source_system' => self::SOURCE, 'entity_type' => 'desk_cases', 'entity_id' => $id, 'external_id' => $id.':'.hash('sha256', $view['opportunity'] ?? 'dossier').':'.$state['version'], 'correlation_id' => $control['operation_key'], 'actor_user_id' => $actor->id, 'action' => $control['action'], 'data_classification' => 'restricted', 'before_data' => json_encode(['version' => $view['version']], JSON_THROW_ON_ERROR), 'after_data' => json_encode(['version' => $state['version'], 'scope' => $view['opportunity'] ?? 'dossier', 'request_hash' => $requestHash, 'snapshot' => Crypt::encryptString(json_encode($state, JSON_THROW_ON_ERROR))], JSON_THROW_ON_ERROR)]); return [...$this->view($actor, $id, $view['opportunity']), 'replayed' => false]; }, 3); } /** Export uses the existing verified binary reader, never a browser-supplied path. * @return array{bytes:string,export_id:string} */ public function bundle(User $actor, string $id, ?string $opportunity, string $exportId, bool $initialScope = false): array { return DB::transaction(function () use ($actor, $id, $opportunity, $exportId, $initialScope) { $this->desk->case($actor, $id, false, true); if ($opportunity === null && ! $initialScope) { $scopes = DB::table('audit_events')->where('source_system', self::SOURCE)->where('entity_type', 'desk_cases')->where('entity_id', $id)->where('correlation_id', $exportId)->where('action', 'apiex_export')->selectRaw("DISTINCT after_data->>'scope' as scope")->get(); abort_unless($scopes->count() === 1, 422, 'Identifique o âmbito da exportação preservada.'); $scope = $scopes->first()->scope; $initialScope = $scope === 'dossier'; $opportunity = $initialScope ? null : $scope; } $view = $this->view($actor, $id, $opportunity, null, $initialScope); $export = collect($this->rows($view['apiex']['exports']))->firstWhere('id', $exportId); abort_unless($export !== null, 404); $records = []; foreach ($export['documents'] as $document) { abort_unless(collect($this->rows($view['documents']))->contains('document_id', $document['document_id']), 403); $record = DB::table('documents')->where('id', $document['document_id'])->whereNull('deleted_at')->lockForUpdate()->first(); abort_unless($record !== null && $record->status === 'active' && $record->malware_scan_status === 'clean' && hash_equals(trim($document['sha256']), trim($record->sha256)) && hash_equals(trim($record->sha256), trim((string) $record->malware_scanned_sha256)), 404, 'Documento da versão preparada indisponível para descarga; o histórico foi preservado.'); app(VerifiedDocumentBinary::class)->authorizeOpportunityAttachment($record); $records[$document['document_id']] = $record; } $disk = Storage::disk('local'); $key = 'investor-preparations/'.$id.'/'.hash('sha256', $view['opportunity'] ?? 'dossier').'/'.$exportId.'.zip.enc'; if ($disk->exists($key)) { try { $sealed = json_decode(Crypt::decryptString($disk->get($key)), true, 512, JSON_THROW_ON_ERROR); } catch (DecryptException|\JsonException $e) { abort(404, 'Pacote preservado indisponível; não foi substituído.'); } abort_unless(($sealed['case_id'] ?? '') === $id && ($sealed['opportunity'] ?? null) === $view['opportunity'] && ($sealed['export_id'] ?? '') === $exportId && ($sealed['fingerprint'] ?? '') === $export['fingerprint'], 404); $bytes = base64_decode($sealed['binary'] ?? '', true); abort_unless(is_string($bytes) && hash_equals($sealed['sha256'] ?? '', hash('sha256', $bytes)), 404); return ['bytes' => $bytes, 'export_id' => $exportId]; } $prepared = $export['prepared']; abort_unless(is_array($prepared), 409, 'Snapshot de exportação indisponível; original conservado.'); $file = tempnam(sys_get_temp_dir(), 'investmoz-apiex-'); abort_unless(is_string($file), 503); $zip = new \ZipArchive; $open = false; try { abort_unless($zip->open($file, \ZipArchive::OVERWRITE) === true, 503); $open = true; $manifest = ['format' => 'investmoz-apiex-preparation/1', 'case_id' => $id, 'opportunity' => $view['opportunity'], 'export' => $export, 'context' => $prepared['context'], 'reused' => $prepared['reused'], 'assessment' => $prepared['assessment'], 'notice' => 'Preparação para entrega externa. Não é formulário oficial, submissão electrónica ou aprovação.']; $index = view('investments.preparation-index', ['workspace' => $prepared, 'manifest' => $manifest])->render(); abort_unless($zip->addFromString('indice.html', $index), 503); abort_unless($zip->addFromString('manifesto.json', json_encode($manifest, JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)), 503); foreach ($export['documents'] as $n => $document) { $record = $records[$document['document_id']]; $response = app(VerifiedDocumentBinary::class)->respond($record); $bytes = $response instanceof BinaryFileResponse ? file_get_contents($response->getFile()->getPathname()) : $response->getContent(); abort_unless(is_string($bytes), 503); abort_unless($zip->addFromString('anexos/'.sprintf('%02d-', $n + 1).preg_replace('/[^\pL\pN._-]+/u', '-', basename($document['file_name'])), $bytes), 503); } abort_unless($zip->close(), 503); $open = false; $bytes = file_get_contents($file); abort_unless(is_string($bytes), 503); $sealed = ['case_id' => $id, 'opportunity' => $view['opportunity'], 'export_id' => $exportId, 'fingerprint' => $export['fingerprint'], 'sha256' => hash('sha256', $bytes), 'binary' => base64_encode($bytes)]; $temporary = $key.'.'.Str::uuid().'.tmp'; try { abort_unless($disk->put($temporary, Crypt::encryptString(json_encode($sealed, JSON_THROW_ON_ERROR))) && $disk->move($temporary, $key), 503, 'Não foi possível preservar o pacote; repita a mesma exportação.'); } finally { $disk->delete($temporary); } return ['bytes' => $bytes, 'export_id' => $exportId]; } finally { if ($open) { $zip->close(); } if (is_file($file)) { unlink($file); } } }); } }