Files
Investidor-V2/app/Services/OpportunityTechnicalIncidents.php
T

135 lines
9.6 KiB
PHP

<?php
namespace App\Services;
use App\Models\Opportunity;
use App\Models\User;
use Illuminate\Database\QueryException;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use PDOException;
use Throwable;
/** Minimal technical metadata, never submitted contents, documents or exception text. */
class OpportunityTechnicalIncidents
{
private const SOURCE = 'portal-opportunity-incident';
public const OPERATIONS = ['submit', 'publish', 'upload_document', 'upload_image', 'prepare_image', 'scan_document'];
public const SCAN_ERRORS = ['scan_dispatch_unconfirmed', 'scan_inconclusive', 'scan_worker_failed', 'scan_cleanup_unconfirmed'];
/** Captured inside the authorized resource transaction; contains no file or model contents.
* @return array<string,mixed>|null */
public function scanContext(User $actor, Opportunity $record): ?array
{
$attributes = $record->getAttributes();
if (! array_key_exists('organization_id', $attributes) || ! array_key_exists('workflow_context', $attributes)
|| ! in_array($record->opportunity_category, OpportunityManagement::CATEGORIES, true)) {
return null;
}
return ['opportunity_id' => $record->id, 'category' => $record->opportunity_category, 'organization_id' => $record->organization_id,
'observed_version' => (int) $record->management_version, 'observed_content_revision' => $record->workflow_context['content_revision'] ?? null,
'observed_state' => app(PortalProfileAccess::class)->state($record), 'reported_for_user_id' => $actor->id];
}
/** The queue executes as a system process; the initiating account is not impersonated.
* @param array<string,mixed> $context
* @return array{reference:string,recorded:bool} */
public function recordScan(array $context, string $reference, string $errorCode): array
{
try {
if (! Str::isUuid($reference) || ! in_array($errorCode, self::SCAN_ERRORS, true)
|| ! is_int($context['opportunity_id'] ?? null) || $context['opportunity_id'] <= 0
|| ! in_array($context['category'] ?? null, OpportunityManagement::CATEGORIES, true)
|| ! array_key_exists('organization_id', $context) || ($context['organization_id'] !== null && (! is_string($context['organization_id']) || ! Str::isUuid($context['organization_id'])))
|| ! is_int($context['reported_for_user_id'] ?? null) || $context['reported_for_user_id'] <= 0
|| ! is_int($context['observed_version'] ?? null) || $context['observed_version'] < 0 || ! is_string($context['observed_state'] ?? null)) {
throw new \LogicException('Invalid internal scan context.');
}
$metadata = ['reference' => $reference, 'opportunity_id' => $context['opportunity_id'], 'category' => $context['category'], 'organization_id' => $context['organization_id'],
'operation' => 'scan_document', 'attempted_etag' => '', 'observed_version' => $context['observed_version'], 'observed_content_revision' => $context['observed_content_revision'] ?? null,
'observed_state' => $context['observed_state'], 'error_code' => $errorCode, 'origin' => 'scan_system', 'reported_for_user_id' => $context['reported_for_user_id']];
$recorded = DB::transaction(function () use ($reference, $metadata) {
DB::table('audit_events')->insertOrIgnore(['id' => $reference, 'correlation_id' => $reference, 'source_system' => self::SOURCE, 'actor_user_id' => null,
'entity_type' => 'opportunity-technical-incident', 'entity_id' => $reference, 'action' => 'workflow_failed', 'data_classification' => 'restricted',
'after_data' => json_encode(['incident' => $metadata], JSON_THROW_ON_ERROR)]);
$event = DB::table('audit_events')->where('id', $reference)->first();
$old = $event ? json_decode($event->after_data, true, 512, JSON_THROW_ON_ERROR)['incident'] ?? null : null;
return $event && $event->source_system === self::SOURCE && $event->entity_type === 'opportunity-technical-incident' && $event->action === 'workflow_failed' && $event->actor_user_id === null
&& is_array($old) && ($old['origin'] ?? null) === 'scan_system' && ($old['operation'] ?? null) === 'scan_document'
&& ($old['opportunity_id'] ?? null) === $metadata['opportunity_id'] && ($old['organization_id'] ?? null) === $metadata['organization_id']
&& ($old['category'] ?? null) === $metadata['category'] && ($old['reported_for_user_id'] ?? null) === $metadata['reported_for_user_id'];
});
if (! $recorded) {
throw new \LogicException('Scan incident reference belongs to another event.');
}
return ['reference' => $reference, 'recorded' => true];
} catch (Throwable) {
try {
Log::warning('Opportunity scan incident could not be stored.', ['reference' => $reference, 'error_code' => in_array($errorCode, self::SCAN_ERRORS, true) ? $errorCode : 'scan_worker_failed']);
} catch (Throwable) { /* Preserve the original worker/dispatch outcome. */
}
return ['reference' => $reference, 'recorded' => false];
}
}
/** Called after the business transaction has unwound.
* @return array{reference:string,recorded:bool} */
public function record(User $actor, Opportunity $before, string $action, string $attemptedEtag, Throwable $failure): array
{
$reference = (string) Str::uuid();
$metadata = ['reference' => $reference, 'opportunity_id' => $before->id, 'category' => $before->opportunity_category, 'organization_id' => $before->organization_id, 'operation' => $action, 'attempted_etag' => $attemptedEtag,
'observed_version' => (int) $before->management_version, 'observed_content_revision' => $before->workflow_context['content_revision'] ?? null,
'observed_state' => app(PortalProfileAccess::class)->state($before),
'error_code' => ($failure instanceof QueryException || $failure instanceof PDOException) ? 'database_failure' : 'workflow_unavailable'];
try {
DB::transaction(fn () => DB::table('audit_events')->insert(['id' => $reference, 'correlation_id' => $reference, 'source_system' => self::SOURCE, 'actor_user_id' => $actor->id, 'entity_type' => 'opportunity-technical-incident', 'entity_id' => $reference,
'action' => 'workflow_failed', 'data_classification' => 'restricted', 'after_data' => json_encode(['incident' => $metadata], JSON_THROW_ON_ERROR)]));
return ['reference' => $reference, 'recorded' => true];
} catch (Throwable) {
// Database failure must not hide the original result or expose its message/SQL.
try {
Log::warning('Opportunity technical incident could not be stored.', ['reference' => $reference, 'error_code' => $metadata['error_code']]);
} catch (Throwable) { /* The response still gives the correlation reference. */
}
return ['reference' => $reference, 'recorded' => false];
}
}
/** Current access and actor ownership are both required, even after revocation.
* @return list<array{reference:string,operation:string,version:int,occurred_at:string,error_code:string,handling:array{version:int,status:string,updated_at:string|null}|null}> */
public function recent(User $actor, Opportunity $record): array
{
app(PortalProfileAccess::class)->authorize($actor, 'view', $record);
$result = [];
foreach (DB::table('audit_events')->where('source_system', self::SOURCE)->where('entity_type', 'opportunity-technical-incident')->whereRaw("after_data->'incident'->>'opportunity_id' = ?", [(string) $record->id])
->whereRaw("COALESCE(after_data->'incident'->>'organization_id','') = ?", [$record->organization_id ?? ''])
->whereRaw("COALESCE(after_data->'incident'->>'category','') = ?", [$record->opportunity_category ?? ''])
->where(function ($query) use ($actor) {
$query->where('actor_user_id', $actor->id)->orWhere(function ($system) use ($actor) {
$system->whereNull('actor_user_id')->whereRaw("after_data->'incident'->>'origin' = 'scan_system'")->whereRaw("after_data->'incident'->>'operation' = 'scan_document'")
->whereRaw("after_data->'incident'->>'reported_for_user_id' = ?", [(string) $actor->id]);
});
})->where('action', 'workflow_failed')->orderByDesc('occurred_at')->orderByDesc('id')->limit(10)->get(['id', 'occurred_at', 'after_data']) as $event) {
$data = json_decode($event->after_data, true)['incident'] ?? null;
if (! is_array($data) || ($data['reference'] ?? null) !== $event->id || ! in_array($data['operation'] ?? null, self::OPERATIONS, true)
|| ! is_int($data['observed_version'] ?? null) || ! in_array($data['error_code'] ?? null, ['database_failure', 'workflow_unavailable', ...self::SCAN_ERRORS], true) || ! is_string($event->occurred_at)) {
continue;
}
$state = app(TechnicalIncidentHandling::class)->summaryFor($event->id);
$result[] = ['reference' => $event->id, 'operation' => $data['operation'], 'version' => $data['observed_version'], 'occurred_at' => $event->occurred_at, 'error_code' => $data['error_code'],
'handling' => $state === null ? null : ['version' => $state['version'], 'status' => $state['status'], 'updated_at' => $state['updated_at']]];
}
return $result;
}
}