canView($actor, $record) && $access->mfa($actor) && ! $access->has($actor, 'auditor') && ! $access->conflict($actor, $record) && $access->scoped($actor, 'validator', $record->organization_id, $record->opportunity_category); } public function seller(User $actor, Opportunity $record): bool { $access = app(PortalProfileAccess::class); return (int) $record->owner_user_id === $actor->id && $access->promoterRecordScope($actor, $record) && $access->canCreate($actor, $record->organization_id, $record->opportunity_category) && $access->operational($actor, 'edit', $record->id); } /** @return array|null */ public function nda(): ?array { $definition = config('private_offers.nda'); if (! is_array($definition) || empty($definition['approval_reference']) || empty($definition['document_id']) || empty($definition['version']) || empty($definition['sha256'])) { return null; } $doc = DB::table('documents')->where('id', $definition['document_id'])->whereNull('deleted_at')->first(); if (! $doc || $doc->status !== 'active' || $doc->malware_scan_status !== 'clean' || (int) $doc->version_no !== (int) $definition['version'] || ! hash_equals(trim($doc->sha256), $definition['sha256']) || ! hash_equals(trim($doc->sha256), trim((string) $doc->malware_scanned_sha256))) { return null; } try { app(VerifiedDocumentBinary::class)->respond($doc); } catch (HttpExceptionInterface $error) { return null; } $result = ['document_id' => $doc->id, 'version' => (int) $doc->version_no, 'sha256' => trim($doc->sha256), 'approval_reference' => $definition['approval_reference']]; return [...$result, 'fingerprint' => hash('sha256', json_encode($result, JSON_THROW_ON_ERROR))]; } private function company(int $id, bool $lock = false): Opportunity { $record = Opportunity::query()->when($lock, fn ($q) => $q->lockForUpdate())->findOrFail($id); abort_unless($record->isPrivateOfferPreparation() && $record->opportunity_category === 'business_sale', 404); return $record; } private function approvedHash(Opportunity $record): string { abort_unless(app(OfferAvailability::class)->record($record)['state'] === 'available', 409, 'A oferta não está disponível.'); $hash = $record->workflow_context['approval']['hash'] ?? null; abort_unless(is_string($hash) && app(PortalProfileAccess::class)->state($record) === 'approved', 409, 'Esta versão não tem aprovação editorial actual.'); try { $snapshot = app(PrivateOfferPreparation::class)->snapshot($record); } catch (ValidationException $error) { abort(409, 'A versão ou os suportes exigem reconferência editorial.'); } abort_unless(hash_equals($hash, $snapshot['hash']), 409, 'A versão mudou; é necessária reconferência editorial.'); return $hash; } /** PostgreSQL jsonb reorders object keys; identity is the version and checksum, not array order. * @param array $nda */ private function sameNda(mixed $accepted, array $nda): bool { if (! is_array($accepted)) { return false; } foreach (['document_id', 'version', 'sha256', 'approval_reference', 'fingerprint'] as $key) { if (($accepted[$key] ?? null) !== $nda[$key]) { return false; } } return true; } /** @return array */ public function overview(User $actor, int $id): array { $record = $this->company($id); // An interested party learns no private title, contacts, documents or financial values. $seller = $this->seller($actor, $record); $nda = $this->nda(); $context = $record->workflow_context['offer_confidentiality'] ?? []; $currentHash = null; try { $currentHash = $this->approvedHash($record); } catch (HttpExceptionInterface $error) { // A previous acceptance remains withdrawable even if the offer was revised. abort_unless($seller || isset($context['acceptances'][(string) $actor->id]), 404); } $history = array_values(array_filter($context['events'] ?? [], fn ($event) => $seller || $event['interested_user_id'] === $actor->id)); return ['offer_id' => $id, 'nda' => $nda, 'nda_pending' => $nda === null, 'version_hash' => $record->workflow_context['approval']['hash'] ?? null, 'etag' => $seller ? app(OpportunityManagement::class)->etag($record) : null, 'can_accept' => ! $seller && $nda !== null && $currentHash !== null && ! app(PortalProfileAccess::class)->conflict($actor, $record), 'can_authorize' => $seller && $nda !== null && $currentHash !== null, 'seller' => $seller, 'requests' => $seller ? array_values($context['acceptances'] ?? []) : [], 'grants' => $seller ? array_values($context['grants'] ?? []) : [], 'history' => array_map(fn ($event) => array_intersect_key($event, array_flip(['id', 'action', 'actor_id', 'at', 'version_hash', 'nda'])), $history), 'acceptance' => $context['acceptances'][(string) $actor->id] ?? null]; } /** @param array $input * @return array */ public function act(User $actor, int $id, array $input): array { $data = validator($input, ['action' => 'required|in:accept,authorize,revoke,withdraw', 'version_hash' => 'required|string|size:64', 'nda_sha256' => 'required_unless:action,revoke,withdraw|string|size:64', 'acceptance_id' => 'required_if:action,authorize|uuid', 'nda_fingerprint' => 'required_if:action,accept,authorize|string|size:64', 'interested_user_id' => 'required_if:action,authorize,revoke|integer|min:1', 'confirm' => 'required|accepted', 'reason' => ['nullable', 'required_if:action,authorize,revoke', 'string', 'max:1000', ...(in_array($input['action'] ?? null, ['authorize', 'revoke'], true) ? ['min:20'] : [])]])->validate(); return DB::transaction(function () use ($actor, $id, $data) { $record = $this->company($id, true); $context = $record->workflow_context; $privacy = $context['offer_confidentiality'] ?? ['acceptances' => [], 'grants' => [], 'events' => []]; $action = $data['action']; $target = in_array($action, ['authorize', 'revoke'], true) ? (string) $data['interested_user_id'] : (string) $actor->id; $before = ['acceptance' => $privacy['acceptances'][$target] ?? null, 'grant' => $privacy['grants'][$target] ?? null]; if (in_array($action, ['authorize', 'revoke'], true)) { abort_unless($this->seller($actor, $record), 403); } elseif ($action === 'accept') { abort_if($this->seller($actor, $record) || app(PortalProfileAccess::class)->conflict($actor, $record), 403); } if (in_array($action, ['accept', 'authorize'], true)) { $nda = $this->nda(); abort_unless($nda !== null, 409, 'O exemplar aprovado do NDA ainda não foi disponibilizado.'); abort_unless(hash_equals($this->approvedHash($record), $data['version_hash']) && hash_equals($nda['sha256'], $data['nda_sha256']) && hash_equals($nda['fingerprint'], $data['nda_fingerprint']), 409); $subject = User::findOrFail((int) $target); abort_unless($subject->hasVerifiedEmail() && ! app(PortalProfileAccess::class)->conflict($subject, $record), 403); if ($action === 'accept') { $privacy['acceptances'][$target] = ['id' => (string) Str::uuid(), 'actor_id' => $actor->id, 'at' => now()->toIso8601String(), 'version_hash' => $data['version_hash'], 'nda' => $nda, 'access_revision' => (int) $subject->portal_access_revision]; unset($privacy['grants'][$target]); // A fresh acceptance never reuses a previous seller decision. } else { $acceptance = $privacy['acceptances'][$target] ?? null; abort_unless($acceptance && $acceptance['id'] === $data['acceptance_id'] && $acceptance['version_hash'] === $data['version_hash'] && $this->sameNda($acceptance['nda'], $nda) && $acceptance['access_revision'] === (int) $subject->portal_access_revision, 409); $privacy['grants'][$target] = ['interested_user_id' => (int) $target, 'seller_id' => $actor->id, 'at' => now()->toIso8601String(), 'version_hash' => $data['version_hash'], 'acceptance_id' => $acceptance['id'], 'nda' => $nda]; } } else { $previous = $privacy[$action === 'withdraw' ? 'acceptances' : 'grants'][$target] ?? null; abort_unless($previous && $previous['version_hash'] === $data['version_hash'], 409); unset($privacy['grants'][$target]); if ($action === 'withdraw') { unset($privacy['acceptances'][$target]); } } $event = ['id' => (string) Str::uuid(), 'action' => $action, 'actor_id' => $actor->id, 'interested_user_id' => (int) $target, 'version_hash' => $data['version_hash'], 'at' => now()->toIso8601String(), 'nda_sha256' => $data['nda_sha256'] ?? null, 'nda' => $nda ?? ($before['acceptance']['nda'] ?? $before['grant']['nda'] ?? null)]; $privacy['events'][] = [...$event, 'reason' => Crypt::encryptString($data['reason'] ?? ''), 'before' => $before, 'after' => ['acceptance' => $privacy['acceptances'][$target] ?? null, 'grant' => $privacy['grants'][$target] ?? null]]; $context['offer_confidentiality'] = $privacy; $record->workflow_context = $context; $record->management_version++; $record->save(); app(PortalProfileAccess::class)->audit($actor, 'offer_confidentiality_'.$action, ['record_id' => $id, ...$event]); return ['recorded' => true, 'event_id' => $event['id']]; }); } /** @return array */ public function read(User $actor, int $id): array { return DB::transaction(function () use ($actor, $id) { $record = $this->company($id, true); $nda = $this->nda(); $privacy = $record->workflow_context['offer_confidentiality'] ?? []; $accept = $privacy['acceptances'][(string) $actor->id] ?? null; $grant = $privacy['grants'][(string) $actor->id] ?? null; $hash = $this->approvedHash($record); $seller = User::find($record->owner_user_id); $subject = User::find($actor->id); $allowed = $nda && $accept && $grant && $seller && $subject && $this->seller($seller, $record) && $subject->hasVerifiedEmail() && $accept['access_revision'] === (int) $subject->portal_access_revision && $grant['seller_id'] === $seller->id && $grant['acceptance_id'] === $accept['id'] && $grant['version_hash'] === $hash && $accept['version_hash'] === $hash && $this->sameNda($grant['nda'], $nda) && $this->sameNda($accept['nda'], $nda) && ! app(PortalProfileAccess::class)->conflict($actor, $record); app(PortalProfileAccess::class)->audit($actor, $allowed ? 'offer_confidential_access' : 'offer_confidential_denied', ['record_id' => $id, 'version_hash' => $hash, 'nda' => $nda, 'acceptance_id' => $accept['id'] ?? null]); // Return the refusal after transaction commit so the denied-access audit survives. if (! $allowed) { return ['denied' => true]; } $business = $record->workflow_context['submission']['payload']['private_offer']['business'] ?? []; foreach (['sector_id' => 'sector', 'secondary_sector_id' => 'secondary_sector'] as $field => $label) { if (isset($business[$field])) { $business[$label] = DB::table('sectors')->where('id', $business[$field])->value('name'); } unset($business[$field]); } return ['offer_id' => $id, 'version_hash' => $hash, 'business' => $business]; }); } }