Files
Investidor-V2/app/Services/PortalProfileAdministration.php
T
nala 287e991486
tests / ci (push) Waiting to run
oportunidades 7
2026-10-13 11:40:29 +02:00

148 lines
11 KiB
PHP

<?php
namespace App\Services;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
class PortalProfileAdministration
{
public function __construct(private PortalProfileAccess $access) {}
public function administrative(User $actor): void
{
abort_unless($this->access->superuser($actor) && $this->access->mfa($actor), 403, 'Superutilizador com MFA recente necessário.');
abort_if($this->access->has($actor, 'auditor'), 403, 'A restrição de leitura do Auditor prevalece.');
}
/** @param array<string,mixed> $input */
public function grant(User $actor, array $input): string
{
$this->administrative($actor);
if (($input['internal_designation'] ?? false) === true) {
return app(PortalInternalDesignation::class)->grant($actor, $input);
}
$data = Validator::make($input, ['user_id' => 'required|integer|exists:users,id', 'role' => ['required', Rule::in(['promoter', 'validator', 'publisher', 'auditor'])],
'organization_id' => 'nullable|uuid|exists:organizations,id', 'opportunity_types' => 'required|array|min:1', 'opportunity_types.*' => ['required', Rule::in(OpportunityManagement::CATEGORIES)],
'responsibilities' => 'sometimes|array', 'responsibilities.*' => ['required', Rule::in(['validation_coordinator', 'technical_reviewer', 'legal_reviewer', 'appeal_reviewer', 'publication_coordinator'])],
'expires_at' => 'nullable|date|after:now', 'reason' => 'required|string|min:20|max:1000'])->validate();
abort_if((int) $data['user_id'] === $actor->id, 403, 'Não pode aprovar a sua atribuição.');
abort_if($data['role'] === 'promoter', 422, 'Promotor exige pedido e revisão documental independente.');
$responsibilities = match ($data['role']) {
'validator' => ['validation_coordinator', 'technical_reviewer', 'legal_reviewer', 'appeal_reviewer'],
'publisher' => ['publication_coordinator'],
default => [],
};
abort_unless(array_diff($data['responsibilities'] ?? [], $responsibilities) === [], 422, 'Responsabilidade incompatível com o papel.');
$subject = User::whereKey($data['user_id'])->firstOrFail();
abort_unless($subject->hasVerifiedEmail(), 422);
if (in_array($data['role'], ['validator', 'publisher'], true)) {
abort_unless($subject->two_factor_secret !== null && $subject->two_factor_confirmed_at !== null, 422, 'Configure e confirme MFA antes de atribuir este papel.');
}
return DB::transaction(function () use ($actor, $data) {
$id = (string) Str::uuid();
$this->insertGrant($id, $actor, $data);
$this->access->audit($actor, 'role_granted', ['assignment_id' => $id, 'subject_user_id' => $data['user_id'], 'role' => $data['role'], 'reason' => $data['reason']]);
return $id;
});
}
/** @param array<string,mixed> $data */
public function insertGrant(string $id, User $approver, array $data): void
{
DB::table('portal_role_assignments')->insert(['id' => $id, 'user_id' => $data['user_id'], 'role' => $data['role'], 'organization_id' => $data['organization_id'] ?? null,
'opportunity_types' => json_encode(array_values(array_unique($data['opportunity_types'])), JSON_THROW_ON_ERROR), 'responsibilities' => json_encode($data['responsibilities'] ?? [], JSON_THROW_ON_ERROR),
'source' => $data['source'] ?? 'independent-administration', 'request_id' => $data['request_id'] ?? null, 'approved_by' => $approver->id,
'starts_at' => now(), 'expires_at' => $data['expires_at'] ?? null, 'created_at' => now(), 'updated_at' => now()]);
}
public function revoke(User $actor, string $id, string $reason): void
{
$this->administrative($actor);
Validator::make(['reason' => $reason], ['reason' => 'required|string|min:20|max:1000'])->validate();
DB::transaction(function () use ($actor, $id, $reason) {
$row = DB::table('portal_role_assignments')->where('id', $id)->lockForUpdate()->first();
abort_unless($row !== null, 404);
if ($row->revoked_at !== null) {
return;
}
DB::table('portal_role_assignments')->where('id', $id)->update(['revoked_at' => now(), 'updated_at' => now()]);
$this->access->revokeSessions(User::whereKey($row->user_id)->firstOrFail());
$this->access->audit($actor, 'role_revoked', ['assignment_id' => $id, 'subject_user_id' => $row->user_id, 'reason' => $reason]);
});
}
/** @param array<string,mixed> $input */
public function request(User $actor, array $input): string
{
abort_unless($this->access->governanceAvailable() && $actor->hasVerifiedEmail(), 403);
$data = Validator::make($input, ['creation_key' => 'required|uuid', 'representation_type' => 'required|in:individual,company,public_entity,association,partner',
'organization_id' => 'nullable|uuid|exists:organizations,id', 'opportunity_types' => 'required|array|min:1', 'opportunity_types.*' => ['required', Rule::in(OpportunityManagement::CATEGORIES)],
'document_ids' => 'required|array|min:1', 'document_ids.*' => 'required|uuid|exists:documents,id', 'reason' => 'required|string|min:20|max:1000'])->validate();
$organization = $data['organization_id'] ?? null;
abort_unless(($data['representation_type'] === 'individual') === ($organization === null), 422, 'Pessoa singular usa âmbito próprio; as entidades exigem organização.');
abort_unless($this->access->represents($actor, $organization), 403, 'Representação ativa de escrita necessária.');
foreach ($data['document_ids'] as $id) {
abort_unless(DB::table('documents')->where('id', $id)->where('uploaded_by', $actor->id)->whereNull('deleted_at')->where('status', 'active')
->where('malware_scan_status', 'clean')->whereColumn('sha256', 'malware_scanned_sha256')->exists(), 422, 'Comprovativo próprio, íntegro e analisado necessário.');
}
return DB::transaction(function () use ($actor, $data, $organization) {
DB::select('SELECT pg_advisory_xact_lock(101026, 40000)');
$existing = DB::table('portal_profile_requests')->where('creation_key', $data['creation_key'])->first();
if ($existing) {
abort_unless((int) $existing->user_id === $actor->id, 403);
abort_unless($existing->organization_id === $organization && $existing->representation_type === $data['representation_type']
&& json_decode($existing->opportunity_types, true) === $data['opportunity_types'] && json_decode($existing->document_ids, true) === $data['document_ids'] && $existing->reason === $data['reason'], 409, 'Chave já usada com outro pedido.');
return $existing->id;
}
$id = (string) Str::uuid();
DB::table('portal_profile_requests')->insert(['id' => $id, 'user_id' => $actor->id, 'organization_id' => $organization,
'representation_type' => $data['representation_type'], 'opportunity_types' => json_encode($data['opportunity_types'], JSON_THROW_ON_ERROR),
'document_ids' => json_encode($data['document_ids'], JSON_THROW_ON_ERROR), 'reason' => $data['reason'], 'creation_key' => $data['creation_key'], 'created_at' => now(), 'updated_at' => now()]);
$this->access->audit($actor, 'promoter_requested', ['request_id' => $id, 'types' => $data['opportunity_types']]);
return $id;
});
}
/** @param array<string,mixed> $input */
public function decide(User $actor, string $id, array $input): void
{
$this->administrative($actor);
$data = Validator::make($input, ['version' => 'required|integer|min:1', 'decision' => 'required|in:approved,rejected', 'reason' => 'required|string|min:20|max:1000',
'verified_identity' => 'required|boolean|accepted_if:decision,approved', 'verified_representation' => 'required|boolean|accepted_if:decision,approved', 'verified_documents' => 'required|boolean|accepted_if:decision,approved',
'approved_types' => 'present|array', 'approved_types.*' => ['required', Rule::in(OpportunityManagement::CATEGORIES)], 'expires_at' => 'nullable|date|after:now'])->validate();
DB::transaction(function () use ($actor, $id, $data) {
$row = DB::table('portal_profile_requests')->where('id', $id)->lockForUpdate()->first();
abort_unless($row !== null, 404);
abort_if((int) $row->user_id === $actor->id, 403, 'Não pode aprovar o seu pedido.');
abort_unless($row->status === 'pending' && (int) $row->version === (int) $data['version'], 409);
$requested = json_decode($row->opportunity_types, true, 512, JSON_THROW_ON_ERROR);
abort_unless(array_diff($data['approved_types'], $requested) === [], 422, 'Só podem ser aprovados tipos pedidos.');
$subject = User::whereKey($row->user_id)->firstOrFail();
if ($data['decision'] === 'approved') {
abort_unless($this->access->represents($subject, $row->organization_id), 403, 'Representação entretanto revogada.');
foreach (json_decode($row->document_ids, true) as $document) {
abort_unless(DB::table('documents')->where('id', $document)->where('uploaded_by', $subject->id)->whereNull('deleted_at')->where('status', 'active')
->where('malware_scan_status', 'clean')->whereColumn('sha256', 'malware_scanned_sha256')->exists(), 422, 'Comprovativo entretanto indisponível.');
}
}
if ($data['decision'] === 'approved') {
abort_unless($data['approved_types'] !== [], 422, 'Escolha ao menos um tipo.');
$this->insertGrant((string) Str::uuid(), $actor, ['user_id' => $subject->id, 'role' => 'promoter', 'organization_id' => $row->organization_id,
'opportunity_types' => $data['approved_types'], 'request_id' => $id, 'source' => 'documented-promoter-request', 'expires_at' => $data['expires_at'] ?? null]);
}
DB::table('portal_profile_requests')->where('id', $id)->update(['status' => $data['decision'], 'version' => $row->version + 1, 'decided_by' => $actor->id,
'decision_reason' => $data['reason'], 'decided_at' => now(), 'updated_at' => now()]);
$this->access->audit($actor, 'promoter_request_decided', ['request_id' => $id, 'decision' => $data['decision'], 'approved_types' => $data['approved_types'], 'reason' => $data['reason'], 'checks' => array_intersect_key($data, array_flip(['verified_identity', 'verified_representation', 'verified_documents']))]);
});
}
}