90 lines
4.4 KiB
PHP
90 lines
4.4 KiB
PHP
<?php
|
|
|
|
namespace App\Services;
|
|
|
|
use App\Models\InterestRequest;
|
|
use Closure;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Str;
|
|
use SensitiveParameter;
|
|
|
|
final class InterestSubmission
|
|
{
|
|
/**
|
|
* @param array<string, mixed> $payload Validated business fields only.
|
|
* @param Closure(): InterestRequest $create
|
|
*/
|
|
public function receive(#[SensitiveParameter] array $payload, #[SensitiveParameter] ?string $key, Closure $create): InterestRequest
|
|
{
|
|
$canonical = array_replace(['phone' => null, 'opportunityReference' => null, 'existingBusinessId' => null,
|
|
'modality' => null, 'investmentRange' => null, 'shareWithProponent' => false], $payload);
|
|
$canonical['email'] = isset($canonical['email']) && trim((string) $canonical['email']) !== ''
|
|
? strtolower(trim((string) $canonical['email']))
|
|
: null;
|
|
$canonical['fullName'] = trim($canonical['fullName']);
|
|
$canonical['message'] = trim($canonical['message']);
|
|
$canonical['privacyConsent'] = true; // FormRequest requires acceptance.
|
|
ksort($canonical);
|
|
$payloadHash = hash_hmac('sha256', json_encode($canonical, JSON_THROW_ON_ERROR), config()->string('app.key'));
|
|
$keyHash = $key === null ? null : hash('sha256', strtolower($key));
|
|
|
|
return DB::transaction(function () use ($keyHash, $payloadHash, $create, $canonical): InterestRequest {
|
|
if ($keyHash !== null) {
|
|
// Serialise concurrent submissions of this key across all web workers.
|
|
DB::select('select pg_advisory_xact_lock(250925, hashtext(?))', [$keyHash]);
|
|
$receipt = DB::table('interest_submission_receipts')->where('key_hash', $keyHash)->first();
|
|
if ($receipt) {
|
|
if (! hash_equals($receipt->payload_hash, $payloadHash)) {
|
|
throw new IdempotencyConflict;
|
|
}
|
|
|
|
return InterestRequest::withTrashed()->where('id', $receipt->interest_request_id)->firstOrFail();
|
|
}
|
|
}
|
|
$interest = $create();
|
|
$integrationId = (string) Str::uuid();
|
|
$interest->forceFill(['integration_id' => $integrationId])->save();
|
|
$this->recordConsent($interest, $integrationId, 'respond_to_interest', config('existing-business.consent_policy_version'));
|
|
if ($canonical['shareWithProponent'] === true || $canonical['shareWithProponent'] === 1 || $canonical['shareWithProponent'] === '1') {
|
|
$this->recordConsent($interest, $integrationId, 'share_contact_with_proponent', config('existing-business.sharing_policy_version'));
|
|
}
|
|
DB::table('outbox_events')->insert([
|
|
'source_system' => 'portal-intake',
|
|
'external_id' => 'ExpressionOfInterestSubmitted:'.$integrationId,
|
|
'aggregate_type' => 'interest_request', 'aggregate_id' => $integrationId,
|
|
'event_type' => 'ExpressionOfInterestSubmitted', 'data_classification' => 'confidential',
|
|
'correlation_id' => $integrationId, 'status' => 'pending',
|
|
'payload' => json_encode(['schema_version' => 1, 'interest_id' => $integrationId], JSON_THROW_ON_ERROR),
|
|
]);
|
|
if ($keyHash !== null) {
|
|
DB::table('interest_submission_receipts')->insert([
|
|
'key_hash' => $keyHash, 'payload_hash' => $payloadHash,
|
|
'interest_request_id' => $interest->id, 'created_at' => now(),
|
|
]);
|
|
}
|
|
|
|
return $interest;
|
|
});
|
|
}
|
|
|
|
private function recordConsent(InterestRequest $interest, string $correlationId, string $purpose, string $policyVersion): void
|
|
{
|
|
DB::table('consents')->insert([
|
|
'id' => (string) Str::uuid(),
|
|
'source_system' => 'portal-intake',
|
|
'external_id' => 'InterestConsent:'.$interest->integration_id.':'.$purpose,
|
|
'version_no' => 1,
|
|
'data_classification' => 'confidential',
|
|
'correlation_id' => $correlationId,
|
|
'subject_ref' => $interest->reference,
|
|
'purpose' => $purpose,
|
|
'status' => 'granted',
|
|
'granted_at' => now(),
|
|
'interest_request_id' => $interest->id,
|
|
'policy_version' => $policyVersion,
|
|
'created_at' => now(),
|
|
'updated_at' => now(),
|
|
]);
|
|
}
|
|
}
|