$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]; }); } /** Private preparation only; never a decision or an authority grant. * @return array */ public function preparation(User $actor, LabourDemand|TrainingCapacity $record): array { app(EducationAccess::class)->authorize($actor); $event = $this->preparations($actor, $record)->orderByRaw("CASE WHEN (after_data->'education_preparation'->>'version') ~ '^[0-9]{1,10}$' THEN (after_data->'education_preparation'->>'version')::bigint END DESC NULLS LAST")->orderByDesc('occurred_at')->orderByDesc('id')->first(['after_data']); $value = $event ? json_decode($event->after_data, true, 512, JSON_THROW_ON_ERROR)['education_preparation'] : null; $snapshot = null; $unavailable = false; if ($value) { try { $decoded = json_decode(Crypt::decryptString($value['snapshot']), true, 512, JSON_THROW_ON_ERROR); $snapshot = Validator::make(['snapshot' => $decoded], $this->preparationRules($decoded))->validate()['snapshot']; } catch (DecryptException|JsonException|ValidationException) { $unavailable = true; } } // Empty reference maps remain JSON objects in the browser, like the declaration props. if (is_array($snapshot)) { if ($snapshot['base']['references'] === []) { $snapshot['base']['references'] = (object) []; } if (is_array($snapshot['pending']) && $snapshot['pending']['base']['references'] === []) { $snapshot['pending']['base']['references'] = (object) []; } } return ['actor_id' => $actor->id, 'kind' => $record instanceof LabourDemand ? 'demands' : 'capacities', 'record_id' => $record->id, 'version' => (int) ($value['version'] ?? 0), 'operation_key' => $value['operation_key'] ?? null, 'snapshot' => $snapshot, 'unavailable' => $unavailable]; } /** @param array $input * @return array */ public function savePreparation(User $actor, string $kind, string $id, array $input): array { app(EducationAccess::class)->authorize($actor); $model = match ($kind) { 'demands' => LabourDemand::class, 'capacities' => TrainingCapacity::class, default => abort(404) }; $data = Validator::make($input, [...$this->preparationRules($input['snapshot'] ?? null), 'actor_id' => 'required|integer|min:1', 'version' => 'required|integer|min:0', 'operation_key' => 'required|uuid'])->validate(); abort_unless((int) $data['actor_id'] === $actor->id, 409, 'A conta mudou. Actualize a entrada antes de guardar a preparação.'); $key = strtolower($data['operation_key']); $snapshot = $data['snapshot']; foreach (['values', 'base'] as $field) { $snapshot[$field]['version_no'] = (int) $snapshot[$field]['version_no']; } $snapshot['base']['quantity'] = (int) $snapshot['base']['quantity']; foreach (['status', 'review_reason'] as $field) { $snapshot['generation'][$field] = (int) $snapshot['generation'][$field]; } if ($snapshot['pending'] !== null) { $snapshot['pending']['values']['version_no'] = (int) $snapshot['pending']['values']['version_no']; $snapshot['pending']['base']['version_no'] = (int) $snapshot['pending']['base']['version_no']; $snapshot['pending']['base']['quantity'] = (int) $snapshot['pending']['base']['quantity']; $snapshot['pending']['values']['client_key'] = strtolower($snapshot['pending']['values']['client_key']); foreach (['reasonGeneration', 'statusGeneration'] as $field) { $snapshot['pending'][$field] = (int) $snapshot['pending'][$field]; } abort_unless($snapshot['pending']['values']['version_no'] === $snapshot['pending']['base']['version_no'], 422); abort_unless($snapshot['pending']['reasonGeneration'] <= $snapshot['generation']['review_reason'] && $snapshot['pending']['statusGeneration'] <= $snapshot['generation']['status'], 422); } abort_unless($snapshot['values']['version_no'] === $snapshot['base']['version_no'], 422); $hash = hash('sha256', json_encode(Arr::sortRecursive(['actor' => $actor->id, 'kind' => $kind, 'id' => $id, 'version' => (int) $data['version'], 'snapshot' => $snapshot]), JSON_THROW_ON_ERROR)); return DB::transaction(function () use ($actor, $model, $id, $data, $snapshot, $key, $hash): array { $record = $model::lockForUpdate()->findOrFail($id); $previous = $this->preparations($actor, $record)->whereRaw("after_data->'education_preparation'->>'operation_key' = ?", [$key])->first(['after_data']); if ($previous) { $receipt = json_decode($previous->after_data, true, 512, JSON_THROW_ON_ERROR)['education_preparation']; abort_unless(hash_equals($receipt['hash'], $hash), 409, 'Esta tentativa de guardar já foi usada com outros dados.'); return $this->preparation($actor, $record); } $current = $this->preparation($actor, $record); abort_unless($current['version'] === (int) $data['version'], 409, 'A preparação mudou noutra janela. Compare antes de substituir.'); abort_if($current['unavailable'], 409, 'A preparação anterior não pôde ser recuperada e foi preservada.'); abort_if($snapshot['base']['version_no'] > (int) $record->getAttribute('version_no'), 422, 'A versão analisada não existe nesta declaração.'); DB::table('audit_events')->insert(['source_system' => self::SOURCE, 'actor_user_id' => $actor->id, 'entity_type' => $record->getTable(), 'entity_id' => $id, 'action' => 'review_preparation_saved', 'data_classification' => 'restricted', 'after_data' => json_encode(['education_preparation' => ['version' => $current['version'] + 1, 'operation_key' => $key, 'hash' => $hash, 'snapshot' => Crypt::encryptString(json_encode($snapshot, JSON_THROW_ON_ERROR))]], JSON_THROW_ON_ERROR)]); return $this->preparation($actor, $record); }); } private function preparations(User $actor, LabourDemand|TrainingCapacity $record): Builder { return 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_preparation_saved'); } /** @return array */ private function preparationRules(mixed $snapshot): array { $rules = ['snapshot' => 'required|array:values,generation,base,pending', 'snapshot.values' => 'required|array:version_no,status,review_reason', 'snapshot.generation' => 'required|array:status,review_reason', 'snapshot.pending' => 'present|nullable|array:values,reasonGeneration,statusGeneration,base']; $roots = ['snapshot']; if (is_array($snapshot) && is_array($snapshot['pending'] ?? null)) { $roots[] = 'snapshot.pending'; $rules['snapshot.pending.values'] = 'required|array:version_no,status,review_reason,client_key'; $rules['snapshot.pending.values.client_key'] = 'required|uuid'; foreach (['reasonGeneration', 'statusGeneration'] as $field) { $rules['snapshot.pending.'.$field] = 'required|integer|min:0|max:1000000000'; } } foreach (['status', 'review_reason'] as $field) { $rules['snapshot.generation.'.$field] = 'required|integer|min:0|max:1000000000'; } foreach ($roots as $root) { $rules[$root.'.values.version_no'] = 'required|integer|min:1'; $rules[$root.'.values.status'] = 'required|in:reviewed,rejected'; $rules[$root.'.values.review_reason'] = 'present|string|max:3000'; $rules[$root.'.base'] = 'required|array:version_no,qualification,quantity,status,period_start,period_end,valid_until,source,reference_date,employer,references'; $rules[$root.'.base.version_no'] = 'required|integer|min:1'; $rules[$root.'.base.quantity'] = 'required|integer|min:0'; foreach (['qualification' => 250, 'status' => 40, 'period_start' => 40, 'period_end' => 40, 'valid_until' => 40, 'source' => 5000, 'reference_date' => 40, 'employer' => 1000] as $field => $max) { $rules[$root.'.base.'.$field] = 'present|string|max:'.$max; } $rules[$root.'.base.references'] = 'present|array:occupation,skill,project,course,location'; $rules[$root.'.base.references.*'] = 'nullable|string|max:1000'; } return $rules; } /** @return array{actor_id:int,commands:list} */ 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 */ 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(); } }