102 lines
7.7 KiB
PHP
102 lines
7.7 KiB
PHP
<?php
|
|
|
|
namespace App\Services;
|
|
|
|
use App\Models\LabourDemand;
|
|
use App\Models\TrainingCapacity;
|
|
use App\Models\User;
|
|
use Illuminate\Contracts\Encryption\DecryptException;
|
|
use Illuminate\Support\Arr;
|
|
use Illuminate\Support\Facades\Crypt;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Validator;
|
|
|
|
final class EducationReviews
|
|
{
|
|
private const SOURCE = 'portal-education-review';
|
|
|
|
/** @param array<string,mixed> $input
|
|
* @return array{version:int,replayed:bool} */
|
|
public function decide(User $actor, string $kind, string $id, array $input): array
|
|
{
|
|
app(EducationAccess::class)->authorize($actor);
|
|
$data = Validator::make($input, ['client_key' => 'nullable|uuid', 'version_no' => 'required|integer|min:1', 'status' => 'required|in:reviewed,rejected', 'review_reason' => 'required|string|min:15|max:3000'])->validate();
|
|
$key = is_string($data['client_key'] ?? null) ? strtolower($data['client_key']) : null;
|
|
$hash = hash('sha256', json_encode(['version_no' => (int) $data['version_no'], 'status' => $data['status'], 'review_reason' => trim($data['review_reason'])], JSON_THROW_ON_ERROR));
|
|
$model = match ($kind) {
|
|
'demands' => LabourDemand::class,'capacities' => TrainingCapacity::class,default => abort(404)
|
|
};
|
|
|
|
return DB::transaction(function () use ($actor, $model, $id, $data, $key, $hash) {
|
|
$record = $model::lockForUpdate()->findOrFail($id);
|
|
if ($key !== null) {
|
|
$event = DB::table('audit_events')->where('source_system', self::SOURCE)->where('actor_user_id', $actor->id)->where('entity_type', $record->getTable())->where('entity_id', $id)->where('action', 'review_committed')
|
|
->whereRaw("after_data->'education_review'->>'key' = ?", [$key])->selectRaw("after_data->'education_review'->>'hash' AS hash,after_data->'education_review'->'version' AS version")->first();
|
|
if ($event) {
|
|
abort_unless($event->hash === $hash, 409, 'Esta tentativa de revisão já foi usada com outros dados. Compare antes de continuar.');
|
|
$version = is_string($event->version) ? json_decode($event->version, true, 512, JSON_THROW_ON_ERROR) : $event->version;
|
|
abort_unless(is_int($version) && $version > 0, 409, 'A revisão anterior não pôde ser identificada com segurança.');
|
|
|
|
return ['version' => $version, 'replayed' => true];
|
|
}
|
|
}
|
|
abort_if((int) $record->getAttribute('submitted_by') === $actor->id, 403, 'Outro gestor deve efectuar a revisão.');
|
|
abort_if((int) $record->getAttribute('version_no') !== (int) $data['version_no'], 409, 'A versão foi alterada. Compare os dados actuais antes de decidir.');
|
|
abort_if($record->getAttribute('valid_until') < now()->toDateString(), 422, 'A fonte expirou.');
|
|
$version = (int) $data['version_no'] + 1;
|
|
$before = ['version' => (int) $record->getAttribute('version_no'), 'status' => $record->getAttribute('status'), 'review_reason' => $record->getAttribute('review_reason'), 'reviewed_by' => $record->getAttribute('reviewed_by')];
|
|
$subject = Arr::only($record->getAttributes(), ['id', 'project_id', 'course_id', 'occupation_id', 'skill_id', 'geo_location_id', 'employer', 'qualification', 'quantity', 'period_start', 'period_end', 'source', 'reference_date', 'valid_until', 'submitted_by']);
|
|
PortalAudit::save($record, ['status' => $data['status'], 'review_reason' => trim($data['review_reason']), 'version_no' => $version, 'reviewed_by' => $actor->id], $actor->id);
|
|
$snapshot = ['subject' => $subject, 'before' => $before, 'after' => ['version' => $version, 'status' => $data['status'], 'review_reason' => $record->getAttribute('review_reason'), 'reviewed_by' => $actor->id]];
|
|
DB::table('audit_events')->insert(['source_system' => self::SOURCE, 'actor_user_id' => $actor->id, 'entity_type' => $record->getTable(), 'entity_id' => $id, 'action' => 'review_committed', 'data_classification' => 'restricted',
|
|
'after_data' => json_encode(['education_review' => ['key' => $key, 'hash' => $key !== null ? $hash : null, 'version' => $version, 'snapshot' => Crypt::encryptString(json_encode($snapshot, JSON_THROW_ON_ERROR))]], JSON_THROW_ON_ERROR)]);
|
|
|
|
return ['version' => $version, 'replayed' => false];
|
|
});
|
|
}
|
|
|
|
/** @return array{actor_id:int,commands:list<array{key:string,version:int}>} */
|
|
public function context(User $actor, LabourDemand|TrainingCapacity $record): array
|
|
{
|
|
app(EducationAccess::class)->authorize($actor);
|
|
$commands = [];
|
|
foreach (DB::table('audit_events')->where('source_system', self::SOURCE)->where('actor_user_id', $actor->id)->where('entity_type', $record->getTable())->where('entity_id', $record->id)->where('action', 'review_committed')
|
|
->selectRaw("after_data->'education_review'->>'key' AS key,after_data->'education_review'->'version' AS version")->get() as $event) {
|
|
$version = is_string($event->version) ? json_decode($event->version, true, 512, JSON_THROW_ON_ERROR) : $event->version;
|
|
if (is_string($event->key) && is_int($version) && $version > 0) {
|
|
$commands[] = ['key' => $event->key, 'version' => $version];
|
|
}
|
|
}
|
|
|
|
return ['actor_id' => $actor->id, 'commands' => $commands];
|
|
}
|
|
|
|
/** Paginated private view data; no mutable paginator escapes the service.
|
|
* @return array<string,mixed> */
|
|
public function history(User $actor, LabourDemand|TrainingCapacity $record): array
|
|
{
|
|
app(EducationAccess::class)->authorize($actor);
|
|
|
|
return DB::table('audit_events as event')->leftJoin('users', 'users.id', '=', 'event.actor_user_id')->where('event.source_system', self::SOURCE)
|
|
->where('event.entity_type', $record->getTable())->where('event.entity_id', $record->id)->where('event.action', 'review_committed')
|
|
->selectRaw("event.id,event.occurred_at,users.name AS actor,event.after_data->'education_review'->'version' AS version,event.after_data->'education_review'->>'snapshot' AS snapshot")
|
|
->orderByRaw("CASE WHEN (event.after_data->'education_review'->>'version') ~ '^[0-9]{1,10}$' THEN (event.after_data->'education_review'->>'version')::bigint END DESC NULLS LAST")
|
|
->orderByDesc('event.occurred_at')->orderByDesc('event.id')->paginate(10, ['*'], 'history_page')->withQueryString()->through(function ($event) {
|
|
$version = is_string($event->version) ? json_decode($event->version, true) : $event->version;
|
|
$content = null;
|
|
try {
|
|
if (is_string($event->snapshot)) {
|
|
$content = json_decode(Crypt::decryptString($event->snapshot), true);
|
|
}
|
|
} catch (DecryptException) {/* Retain metadata and encrypted original; do not invent a past reason. */
|
|
}
|
|
$available = is_array($content) && isset($content['after']) && is_array($content['after']) && ($content['after']['version'] ?? null) === $version && is_string($content['after']['review_reason'] ?? null)
|
|
&& in_array($content['after']['status'] ?? null, ['reviewed', 'rejected'], true);
|
|
|
|
return ['id' => $event->id, 'version' => is_int($version) && $version > 0 ? $version : null, 'actor' => $event->actor, 'occurred_at' => $event->occurred_at, 'available' => $available,
|
|
'status' => $available ? $content['after']['status'] : null, 'reason' => $available ? $content['after']['review_reason'] : null,
|
|
'previous_reason' => $available && is_string($content['before']['review_reason'] ?? null) ? $content['before']['review_reason'] : null];
|
|
})->toArray();
|
|
}
|
|
}
|