'Instituição financeira', 'fund' => 'Fundo', 'programme' => 'Programa de apoio', 'incentive' => 'Incentivo']; public const WINDOWS = ['dated' => 'Datas documentadas', 'rolling' => 'Recepção contínua documentada', 'unknown' => 'Janela por confirmar', 'closed' => 'Encerrada segundo a fonte']; public function installed(): bool { return Schema::hasTable('funding_programme_interests') && Schema::hasTable('funding_programme_versions'); } public function access(?User $actor): bool { return app(DocumentManagementAccess::class)->catalogueEditor($actor); } public function guard(?User $actor): void { abort_unless($this->installed(), 404); abort_unless($this->access($actor), 403); } /** @param array $reference */ public function matchesReference(User $actor, string $id, array $reference): bool { $this->guard($actor); $row = DB::table('funding_programmes')->where('id', $id)->firstOrFail(); $expected = $this->normalize($actor, $reference, false); $actual = json_decode($row->draft, true, 512, JSON_THROW_ON_ERROR); abort_unless(is_array($actual), 503); return json_encode(Arr::sortRecursive($actual), JSON_THROW_ON_ERROR) === json_encode(Arr::sortRecursive($expected), JSON_THROW_ON_ERROR); } public function publicQuery(): Builder { abort_unless($this->installed(), 404); return DB::table('funding_programmes as p')->join('funding_programme_versions as v', fn ($join) => $join->on('v.id', '=', 'p.published_version_id')->on('v.programme_id', '=', 'p.id')) ->where('v.published_at', '<=', now()); } public function readableVersions(): Builder { return DB::table('funding_programme_versions')->where('published_at', '<=', now()) ->whereIn('programme_id', $this->publicQuery()->select('p.id')); } /** @param array $filters */ public function catalogueQuery(array $filters): Builder { $query = $this->publicQuery(); app(FundingInstitutionDetails::class)->applyFilters($query, $filters); $includeUnknown = ($filters['certainty'] ?? 'include_unknown') === 'include_unknown'; if (! empty($filters['sector'])) { $query->where(function (Builder $scope) use ($filters, $includeUnknown): void { $scope->whereRaw("v.payload->>'sectors_scope' = 'all'") ->orWhereJsonContains('v.payload->sector_ids', (int) $filters['sector']); if ($includeUnknown) { $scope->orWhereRaw("v.payload->>'sectors_scope' = 'unknown'"); } }); } if (! empty($filters['territory']) && ($filters['location_mode'] ?? '') !== 'physical') { $path = array_column(app(InvestmentGeography::class)->path($filters['territory']), 'id'); $query->where(function (Builder $scope) use ($filters, $path, $includeUnknown): void { $scope->whereRaw("v.payload->>'kind' = 'institution'")->orWhereRaw("v.payload->>'coverage' = 'national'") ->orWhereJsonContains('v.payload->territory_ancestor_ids', $filters['territory']); foreach ($path as $id) { $scope->orWhereJsonContains('v.payload->territory_ids', $id); } if ($includeUnknown) { $scope->orWhereRaw("v.payload->>'coverage' = 'unknown'"); } }); } if (! empty($filters['state'])) { $today = today()->toDateString(); $state = $filters['state']; if ($state === 'unknown') { $query->whereRaw("(jsonb_array_length(v.payload->'windows') = 0 OR EXISTS (SELECT 1 FROM jsonb_array_elements(v.payload->'windows') w WHERE w->>'mode' = 'unknown'))"); } elseif ($state === 'closed') { $query->whereRaw("EXISTS (SELECT 1 FROM jsonb_array_elements(v.payload->'windows') w WHERE w->>'mode' = 'closed' OR (w->>'mode' = 'dated' AND w->>'end_on' < ?))", [$today]); } elseif ($state === 'upcoming') { $query->whereRaw("EXISTS (SELECT 1 FROM jsonb_array_elements(v.payload->'windows') w WHERE w->>'mode' = 'dated' AND w->>'start_on' > ?)", [$today]); } else { $query->whereRaw("EXISTS (SELECT 1 FROM jsonb_array_elements(v.payload->'windows') w WHERE w->>'mode' IN ('dated','rolling') AND COALESCE(NULLIF(w->>'start_on',''),?) <= ? AND COALESCE(NULLIF(w->>'end_on',''),?) >= ?)", [$today, $today, $today, $today]); } } return $query; } /** @param array $record * @param array $filters * @return array */ public function filterExplanation(array $record, array $filters): array { $payload = $record['payload']; if ($payload['kind'] === 'institution') { $products = array_values(array_filter($payload['products'] ?? [], fn ($p) => app(FundingInstitutionDetails::class)->match($p, $filters))); if (($filters['location_mode'] ?? '') === 'physical') { return ['institution' => 'Pesquisa por presença física documentada, não por cobertura de financiamento. Confira a precisão do local e as indicações por confirmar na ficha.']; } return ['institution' => $products === [] ? 'Instituição identificada; produtos, cobertura e disponibilidade por confirmar. Não é uma correspondência confirmada.' : 'Consulte os produtos correspondentes e as condições por confirmar. Sede e balcões não determinam a cobertura do financiamento.']; } $result = []; if (! empty($filters['sector'])) { $result['sector'] = $payload['sectors_scope'] === 'unknown' ? 'Âmbito sectorial por confirmar; não é uma correspondência confirmada.' : 'O sector está abrangido pelo âmbito documentado do programa.'; } if (! empty($filters['territory'])) { $path = array_column(app(InvestmentGeography::class)->path($filters['territory']), 'id'); $result['territory'] = $payload['coverage'] === 'unknown' ? 'Cobertura territorial por confirmar; não é uma correspondência confirmada.' : ($payload['coverage'] === 'national' || array_intersect($path, $payload['territory_ids']) !== [] ? 'O local está abrangido pela cobertura documentada do programa.' : 'O programa abrange uma parte da área seleccionada; confira os locais na ficha.'); } return $result; } /** @return array */ public function published(string $id): array { $row = $this->publicQuery()->where('p.id', $id)->firstOrFail(['p.id', 'p.code', 'v.id as version_id', 'v.version', 'v.payload', 'v.published_at']); return $this->present($row); } /** @return array */ public function present(\stdClass $row): array { $payload = json_decode($row->payload, true, 512, JSON_THROW_ON_ERROR); foreach ($payload['windows'] as &$window) { $window['state'] = $this->windowState($window); } unset($window); $payload['documents'] = array_map(fn ($document) => [...$document, 'available' => app(Procurement::class)->publicDocuments()->where('r.id', $document['resource_id'])->where('d.id', $document['document_id']) ->where('d.sha256', $document['sha256'])->where('d.version_no', $document['version'])->exists(), ], $payload['document_snapshots'] ?? []); return ['id' => $row->id, 'code' => $row->code, 'version_id' => $row->version_id, 'version' => (int) $row->version, 'published_at' => $row->published_at, 'payload' => $payload]; } /** @param array $window */ public function windowState(array $window): string { if ($window['mode'] === 'closed') { return 'closed'; } if ($window['mode'] === 'unknown') { return 'unknown'; } if (! empty($window['end_on']) && $window['end_on'] < today()->toDateString()) { return 'closed'; } if (! empty($window['start_on']) && $window['start_on'] > today()->toDateString()) { return 'upcoming'; } return 'open'; } /** Resolve only the exact library binary incorporated in the current public version. */ public function download(string $id, string $version, int $resource): Response|BinaryFileResponse { $record = $this->published($id); abort_unless($record['version_id'] === $version, 404); $snapshot = null; foreach ($record['payload']['document_snapshots'] ?? [] as $candidate) { if ($candidate['resource_id'] === $resource) { $snapshot = $candidate; break; } } abort_unless($snapshot !== null, 404); $document = app(Procurement::class)->publicDocuments()->where('r.id', $resource) ->where('d.id', $snapshot['document_id'])->where('d.version_no', $snapshot['version']) ->where('d.sha256', $snapshot['sha256'])->first(['d.*']); abort_unless($document !== null, 404); return app(VerifiedDocumentBinary::class)->respond($document); } /** @param array $input */ public function save(User $actor, ?string $id, array $input): string { $this->guard($actor); $data = Validator::make($input, ['version' => 'required|integer|min:0', 'code' => 'required|string|regex:/^[a-z0-9][a-z0-9-]{0,79}$/D', 'reason' => 'required|string|min:20|max:2000', 'draft' => 'required|array'])->validate(); $draft = $this->normalize($actor, $data['draft'], false); return DB::transaction(function () use ($actor, $id, $data, $draft): string { DB::select('select pg_advisory_xact_lock(261102, hashtext(?))', [$data['code']]); $row = $id ? DB::table('funding_programmes')->where('id', $id)->lockForUpdate()->firstOrFail() : null; abort_unless((int) ($row->version ?? 0) === (int) $data['version'], 409, 'A ficha foi alterada. Recupere a versão actual antes de guardar.'); abort_if($row && $row->code !== $data['code'], 422, 'O identificador da ficha é estável.'); if (! $row && DB::table('funding_programmes')->where('code', $data['code'])->exists()) { throw ValidationException::withMessages(['code' => 'Este identificador já pertence a uma ficha. Abra-a para actualizar ou acrescentar uma janela.']); } if ($draft['kind'] === 'institution') { DB::select('select pg_advisory_xact_lock(261102, hashtext(?))', ['institution:'.$draft['institution_key']]); $duplicate = DB::table('funding_programmes')->whereRaw("draft->>'kind' = 'institution'")->whereRaw("draft->>'institution_key' = ?", [$draft['institution_key']])->when($id, fn ($q) => $q->where('id', '<>', $id))->exists(); if ($duplicate) { throw ValidationException::withMessages(['institution_key' => 'A instituição já tem ficha. Reutilize a identidade existente; não substitua as suas edições.']); } } $id ??= (string) Str::uuid(); DB::table('funding_programmes')->updateOrInsert(['id' => $id], ['code' => $data['code'], 'version' => (int) $data['version'] + 1, 'draft' => json_encode($draft, JSON_THROW_ON_ERROR), 'created_by' => $row->created_by ?? $actor->id, 'created_at' => $row->created_at ?? now(), 'updated_at' => now()]); $this->audit($actor, $id, 'draft_saved', ['version' => (int) $data['version'] + 1, 'reason' => $data['reason']]); return $id; }); } /** @param array $input * @return array */ private function normalize(User $actor, array $input, bool $publishing): array { $required = $publishing ? 'required' : 'nullable'; $rules = [ 'title' => "$required|string|max:250", 'kind' => ['required', Rule::in(array_keys(self::KINDS))], 'summary' => "$required|string|max:4000", 'manager_name' => 'nullable|string|max:250', 'manager_organization_id' => ['nullable', 'uuid', Rule::exists('organizations', 'id')->whereNull('deleted_at')], 'source_url' => 'nullable|url:http,https|max:2000', 'source_document_name' => 'nullable|string|max:250', 'source_document_sha256' => 'nullable|string|regex:/^[a-f0-9]{64}$/D', 'source_reference' => "$required|string|max:1500", 'source_checked_on' => "$required|date_format:Y-m-d|before_or_equal:today", 'source_published_on' => 'nullable|date_format:Y-m-d', 'source_status' => 'required|in:identified,unknown,example', 'coverage' => 'required|in:national,territorial,unknown', 'coverage_text' => 'nullable|string|max:2000', 'territory_ids' => 'array|max:30', 'territory_ids.*' => 'required|uuid|distinct', 'sectors_scope' => 'required|in:all,selected,unknown', 'sectors_text' => 'nullable|string|max:2000', 'sector_ids' => 'array|max:40', 'sector_ids.*' => ['required', 'integer', 'distinct', Rule::in(Sector::choices()->whereNotNull('published_at')->pluck('id')->all())], 'resource_ids' => 'array|max:30', 'resource_ids.*' => 'required|integer|distinct|exists:portal_resources,id', 'funding_needs' => 'array|max:6', 'funding_needs.*' => ['required', 'distinct', Rule::in(array_keys(FundingInstitutionDetails::NEEDS))], 'criteria' => 'array|max:30', 'windows' => 'array|max:20', 'windows.*' => 'required|array:code,title,mode,start_on,end_on,coverage,coverage_text,territory_ids,source_url,source_reference,checked_on,application_url,criteria', 'windows.*.code' => 'required|string|regex:/^[a-z0-9][a-z0-9-]{0,79}$/D|distinct', 'windows.*.title' => "$required|string|max:250", 'windows.*.mode' => ['required', Rule::in(array_keys(self::WINDOWS))], 'windows.*.start_on' => 'nullable|date_format:Y-m-d', 'windows.*.end_on' => 'nullable|date_format:Y-m-d', 'windows.*.coverage' => 'required|in:programme,national,territorial,unknown', 'windows.*.coverage_text' => 'nullable|string|max:2000', 'windows.*.territory_ids' => 'array|max:30', 'windows.*.territory_ids.*' => 'required|uuid|distinct', 'windows.*.source_url' => "$required|url:http,https|max:2000", 'windows.*.source_reference' => "$required|string|max:1500", 'windows.*.checked_on' => "$required|date_format:Y-m-d|before_or_equal:today", 'windows.*.application_url' => 'nullable|url:http,https|max:2000', 'windows.*.criteria' => 'array|max:30', ]; foreach (['criteria', 'windows.*.criteria'] as $prefix) { $rules[$prefix.'.*'] = 'required|array:code,question,applies_to,expected,source_reference,explanation'; $rules[$prefix.'.*.code'] = 'required|string|regex:/^[a-z0-9][a-z0-9-]{0,79}$/D'; $rules[$prefix.'.*.question'] = "$required|string|max:500"; $rules[$prefix.'.*.applies_to'] = 'required|in:all,individual,entity'; $rules[$prefix.'.*.expected'] = 'required|in:yes,no'; $rules[$prefix.'.*.source_reference'] = "$required|string|max:1500"; $rules[$prefix.'.*.explanation'] = "$required|string|max:2000"; } $data = Validator::make($input, $rules)->validate(); if ($publishing && empty($data['source_url'])) { Validator::make($data, ['source_document_name' => 'required|string', 'source_document_sha256' => 'required|string|size:64'])->validate(); } if ($data['kind'] === 'institution') { $data = [...$data, ...app(FundingInstitutionDetails::class)->normalize($input, $publishing)]; } $data = [...$data, 'territory_ids' => $data['territory_ids'] ?? [], 'sector_ids' => $data['sector_ids'] ?? [], 'resource_ids' => $data['resource_ids'] ?? [], 'criteria' => $data['criteria'] ?? [], 'windows' => $data['windows'] ?? []]; if ($data['coverage'] !== 'territorial' && $data['territory_ids'] !== []) { throw ValidationException::withMessages(['territory_ids' => 'Seleccione cobertura territorial para associar locais específicos.']); } if ($data['sectors_scope'] !== 'selected' && $data['sector_ids'] !== []) { throw ValidationException::withMessages(['sector_ids' => 'Seleccione o âmbito por sectores para associar sectores específicos.']); } if ($publishing && $data['coverage'] === 'territorial' && $data['territory_ids'] === []) { throw ValidationException::withMessages(['territory_ids' => 'Seleccione os locais documentados ou assinale a cobertura como por determinar.']); } if ($data['manager_organization_id'] ?? null) { $data['manager_name'] = DB::table('organizations')->where('id', $data['manager_organization_id'])->value('name'); } if ($publishing && empty($data['manager_name'])) { throw ValidationException::withMessages(['manager_name' => 'Seleccione a entidade do cadastro ou identifique o gestor indicado pela fonte.']); } if ($publishing && $data['source_status'] !== 'identified') { throw ValidationException::withMessages(['source_status' => 'Identifique a fonte efectiva. Conteúdo de exemplo ou origem por identificar não confirma um programa real.']); } if ($publishing && $data['sectors_scope'] === 'selected' && $data['sector_ids'] === []) { throw ValidationException::withMessages(['sector_ids' => 'Seleccione os sectores documentados ou assinale o âmbito como por determinar.']); } $territories = $data['territory_ids']; $codes = []; foreach (array_merge([$data['criteria']], array_column($data['windows'], 'criteria')) as $criteria) { foreach ($criteria as $criterion) { if (in_array($criterion['code'], $codes, true)) { throw ValidationException::withMessages(['criteria' => 'Cada critério deve ter um identificador único na ficha, sem repetir perguntas comuns por janela.']); } $codes[] = $criterion['code']; } } foreach ($data['windows'] as $index => &$window) { $window['criteria'] ??= []; $window['territory_ids'] ??= []; if ($window['coverage'] !== 'territorial' && $window['territory_ids'] !== []) { throw ValidationException::withMessages(['windows.'.$index.'.territory_ids' => 'Associe locais apenas numa janela de cobertura territorial.']); } if ($publishing && $window['coverage'] === 'territorial' && $window['territory_ids'] === []) { throw ValidationException::withMessages(['windows.'.$index.'.territory_ids' => 'Seleccione os locais documentados ou assinale a cobertura como por determinar.']); } $territories = array_merge($territories, $window['territory_ids']); if ($publishing && $window['mode'] === 'dated' && empty($window['end_on'])) { throw ValidationException::withMessages(['windows.'.$index.'.end_on' => 'Uma janela com datas documentadas precisa da data-limite.']); } if (! empty($window['start_on']) && ! empty($window['end_on']) && $window['start_on'] > $window['end_on']) { throw ValidationException::withMessages(['windows.'.$index.'.end_on' => 'A data-limite não pode anteceder a abertura.']); } if (in_array($window['mode'], ['rolling', 'unknown'], true) && (! empty($window['start_on']) || ! empty($window['end_on']))) { throw ValidationException::withMessages(['windows.'.$index.'.mode' => 'Conserve datas documentadas ou escolha uma janela sem datas; não misture as duas indicações.']); } } unset($window); foreach (array_unique($territories) as $territory) { if (! app(InvestmentGeography::class)->publicReferences()->where('id', $territory)->exists()) { throw ValidationException::withMessages(['territory_ids' => 'Seleccione referências do cadastro territorial público.']); } } if ($publishing) { $data['territory_paths'] = array_map(fn ($id) => array_map(fn ($node) => ['id' => $node->id, 'name' => $node->name, 'type' => $node->location_type], app(InvestmentGeography::class)->path($id)), $data['territory_ids']); $data['territory_ancestor_ids'] = array_values(array_unique(array_merge([], ...array_map(fn ($path) => array_column($path, 'id'), $data['territory_paths'])))); $data['sector_snapshots'] = Sector::choices()->whereIn('id', $data['sector_ids'])->get(['id', 'name'])->toArray(); foreach ($data['windows'] as &$window) { $window['territory_paths'] = array_map(fn ($id) => array_map(fn ($node) => ['id' => $node->id, 'name' => $node->name, 'type' => $node->location_type], app(InvestmentGeography::class)->path($id)), $window['territory_ids']); } unset($window); } foreach ($data['resource_ids'] as $resource) { $public = app(Procurement::class)->publicDocuments()->where('r.id', $resource)->first(['r.title', 'd.id', 'd.version_no', 'd.sha256']); $document = DB::table('portal_resources')->where('id', $resource)->value('document_id'); if (! $public && ($publishing || ! app(DocumentManagementAccess::class)->documents($actor)->where('id', $document)->exists())) { throw ValidationException::withMessages(['resource_ids' => 'Um documento publicado exige acesso público, direitos e análise de segurança válidos na biblioteca.']); } if ($publishing) { $data['document_snapshots'][] = ['resource_id' => $resource, 'document_id' => $public->id, 'version' => (int) $public->version_no, 'sha256' => trim($public->sha256), 'title' => $public->title]; } } return $data; } /** @return array */ public function preview(User $actor, string $id): array { $this->guard($actor); $row = DB::table('funding_programmes')->where('id', $id)->firstOrFail(); $payload = $this->normalize($actor, json_decode($row->draft, true, 512, JSON_THROW_ON_ERROR), true); unset($payload['manager_organization_id']); $plan = ['id' => $id, 'code' => $row->code, 'version' => (int) $row->version, 'payload' => $payload]; $plan['hash'] = hash_hmac('sha256', json_encode(Arr::sortRecursive($plan), JSON_THROW_ON_ERROR), config('app.key')); return $plan; } /** @param array $input */ public function publish(User $actor, string $id, array $input): string { $this->guard($actor); $data = Validator::make($input, ['version' => 'required|integer|min:1', 'hash' => 'required|string|size:64', 'reason' => 'required|string|min:20|max:2000', 'confirm' => 'accepted'])->validate(); return DB::transaction(function () use ($actor, $id, $data): string { $row = DB::table('funding_programmes')->where('id', $id)->lockForUpdate()->firstOrFail(); $plan = $this->preview($actor, $id); abort_unless((int) $row->version === (int) $data['version'] && hash_equals($plan['hash'], $data['hash']), 409, 'A prévia mudou. Confira novamente antes de publicar.'); if ($row->published_version_id) { $previous = DB::table('funding_programme_versions')->where('id', $row->published_version_id)->firstOrFail(); if (json_decode($previous->payload, false, 512, JSON_THROW_ON_ERROR) == json_decode(json_encode($plan['payload'], JSON_THROW_ON_ERROR), false, 512, JSON_THROW_ON_ERROR)) { return $previous->id; } } $version = (string) Str::uuid(); $publicationNumber = max((int) $row->version, (int) DB::table('funding_programme_versions')->where('programme_id', $id)->max('version') + 1); DB::table('funding_programme_versions')->insert(['id' => $version, 'programme_id' => $id, 'version' => $publicationNumber, 'payload' => json_encode($plan['payload'], JSON_THROW_ON_ERROR), 'reason' => $data['reason'], 'actor_id' => $actor->id, 'published_at' => now()]); DB::table('funding_programmes')->where('id', $id)->update(['published_version_id' => $version, 'updated_at' => now()]); $this->audit($actor, $id, 'published', ['version_id' => $version, 'version' => $publicationNumber, 'draft_version' => (int) $row->version, 'reason' => $data['reason'], 'effect' => 'new_public_version_only']); return $version; }); } public function withdraw(User $actor, string $id, int $version, string $reason): void { $this->guard($actor); Validator::make(['reason' => $reason], ['reason' => 'required|string|min:20|max:2000'])->validate(); DB::transaction(function () use ($actor, $id, $version, $reason): void { $row = DB::table('funding_programmes')->where('id', $id)->lockForUpdate()->firstOrFail(); abort_unless((int) $row->version === $version, 409); DB::table('funding_programmes')->where('id', $id)->update(['published_version_id' => null, 'version' => $version + 1, 'updated_at' => now()]); $this->audit($actor, $id, 'withdrawn', ['previous_version_id' => $row->published_version_id, 'reason' => $reason]); }); } /** @param array $input * @return array */ public function assess(string $id, array $input): array { $record = $this->published($id); $data = Validator::make($input, ['version_id' => 'required|uuid', 'applicant_kind' => 'required|in:individual,entity,unknown', 'window_code' => 'nullable|string|max:80', 'answers' => 'present|array|max:60', 'answers.*' => 'required|in:yes,no,unknown'])->validate(); abort_unless($data['version_id'] === $record['version_id'], 409, 'A versão pública mudou. Recupere os critérios actuais antes de conferir.'); $window = null; foreach ($record['payload']['windows'] as $candidate) { if ($candidate['code'] === ($data['window_code'] ?? null)) { $window = $candidate; break; } } abort_if(! empty($data['window_code']) && ! $window, 422); $criteria = array_merge($record['payload']['criteria'], $window['criteria'] ?? []); abort_if(array_diff(array_keys($data['answers']), array_column($criteria, 'code')) !== [], 422, 'A resposta deve pertencer aos critérios desta versão e janela.'); $results = []; foreach ($criteria as $criterion) { $status = 'unknown'; if ($criterion['applies_to'] !== 'all' && $data['applicant_kind'] !== 'unknown' && $criterion['applies_to'] !== $data['applicant_kind']) { $status = 'not_applicable'; } elseif (($criterion['applies_to'] === 'all' || $criterion['applies_to'] === $data['applicant_kind']) && ($data['answers'][$criterion['code']] ?? 'unknown') !== 'unknown') { $status = $data['answers'][$criterion['code']] === $criterion['expected'] ? 'matches' : 'does_not_match'; } $results[] = [...$criterion, 'status' => $status]; } return ['version_id' => $record['version_id'], 'window_code' => $window['code'] ?? null, 'window_state' => $window ? $this->windowState($window) : 'unknown', 'criteria' => $results, 'counts' => array_count_values(array_column($results, 'status')), 'notice' => 'Correspondência preliminar das respostas declaradas com os critérios citados. Não é aprovação, candidatura enviada, concessão de financiamento ou benefício fiscal.']; } public function saveInterest(User $actor, string $id, string $version, bool $consent): void { abort_unless($consent, 422, 'Confirme que pretende guardar este programa na sua área privada.'); DB::transaction(function () use ($actor, $id, $version): void { // Serialize this person's bookmarks without blocking audit FK KEY SHARE locks. DB::table('users')->where('id', $actor->id)->lock('FOR NO KEY UPDATE')->firstOrFail(); DB::table('funding_programmes')->where('id', $id)->lockForUpdate()->firstOrFail(); $record = $this->published($id); abort_unless($record['version_id'] === $version, 409, 'A versão pública mudou. Recupere a ficha antes de guardar.'); $saved = DB::table('funding_programme_interests')->where('user_id', $actor->id)->where('programme_id', $id)->first(); if ($saved && $saved->removed_at === null) { return; } DB::table('funding_programme_interests')->updateOrInsert(['user_id' => $actor->id, 'programme_id' => $id], [ 'version_id' => $version, 'saved_at' => now(), 'removed_at' => null, ]); $this->audit($actor, $id, 'interest_saved', ['version_id' => $version, 'scope' => 'private_bookmark_not_application']); }); } public function removeInterest(User $actor, string $id): void { DB::transaction(function () use ($actor, $id): void { DB::table('users')->where('id', $actor->id)->lock('FOR NO KEY UPDATE')->firstOrFail(); if (DB::table('funding_programme_interests')->where('user_id', $actor->id)->where('programme_id', $id)->whereNull('removed_at')->update(['removed_at' => now()])) { $this->audit($actor, $id, 'interest_removed', ['scope' => 'private_bookmark']); } }); } /** @param array $data */ private function audit(User $actor, string $id, string $action, array $data): void { DB::table('audit_events')->insert(['source_system' => 'funding-catalogue', 'entity_type' => 'funding_programmes', 'entity_id' => $id, 'actor_user_id' => $actor->id, 'action' => $action, 'data_classification' => 'restricted', 'after_data' => json_encode($data, JSON_THROW_ON_ERROR)]); } }