218 lines
13 KiB
PHP
218 lines
13 KiB
PHP
<?php
|
|
|
|
namespace App\Services;
|
|
|
|
use App\Models\Opportunity;
|
|
use App\Models\User;
|
|
use Illuminate\Support\Facades\Crypt;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Str;
|
|
use Illuminate\Validation\ValidationException;
|
|
use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface;
|
|
|
|
/** The same opportunity owns consent, grants and history. No automatic publication. */
|
|
final class PrivateOfferConfidentiality
|
|
{
|
|
public function internal(User $actor, Opportunity $record): bool
|
|
{
|
|
$access = app(PortalProfileAccess::class);
|
|
|
|
return $access->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<string,mixed>|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<string,mixed> $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<string,mixed> */
|
|
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<string,mixed> $input
|
|
* @return array<string,mixed> */
|
|
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<string,mixed> */
|
|
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];
|
|
});
|
|
}
|
|
}
|