resolvemos o problema dos ZEE e ZFI
tests / ci (push) Canceled after 0s

This commit is contained in:
2026-10-07 09:08:43 +02:00
parent e65cb26901
commit 59d108a576
37 changed files with 1514 additions and 102 deletions
+211 -37
View File
@@ -13,6 +13,7 @@ use Illuminate\Database\QueryException;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
@@ -108,7 +109,11 @@ class PortalServicesController extends Controller
])->paginate(20)->withQueryString();
$pageResult->getCollection()->transform(function ($resource) {
$resource->links = DB::table('portal_resource_links')->where('resource_id', $resource->id)
->orderBy('target_type')->orderBy('target_key')->get(['target_type', 'target_key', 'relationship', 'applicability', 'condition_text', 'basis_reference', 'review_status']);
->orderBy('target_type')->orderBy('target_key')->get($this->resourceLinkColumns());
$resource->links->transform(fn ($link) => (object) [...(array) $link,
'target_label' => $link->target_type === 'requirement'
? config('document-requirements.catalogue.'.$link->target_key.'.label', $link->target_key)
: null]);
$resource->download_url = route('services.download', ['id' => $resource->id]);
$resource->view_url = in_array($resource->media_type, ['application/pdf', 'image/jpeg', 'image/png', 'image/webp'], true)
? route('services.document.view', ['id' => $resource->id]) : null;
@@ -186,8 +191,10 @@ class PortalServicesController extends Controller
$resources->transform(function ($resource) use ($matches): array {
$matched = DB::table('portal_resource_links')->where('resource_id', $resource->id)->whereIn('target_type', array_column($matches, 'type'))
->whereIn('target_key', array_column($matches, 'key'))->orderBy('target_type')->first(['target_type', 'target_key', 'applicability', 'condition_text', 'basis_reference']);
$evidence = DB::table('portal_resource_links')->where('resource_id', $resource->id)->where('target_type', 'requirement')
->where('review_status', 'confirmed')->orderBy('target_key')->get(['target_key', 'basis_reference', 'review_status']);
$evidence = $this->requirementEvidenceEnabled()
? DB::table('portal_resource_links')->where('resource_id', $resource->id)->where('target_type', 'requirement')
->where('review_status', 'confirmed')->orderBy('target_key')->get(['target_key', 'basis_reference', 'review_status'])
: collect();
return [...(array) $resource,
'download_url' => route('services.download', ['id' => $resource->id]),
'recommendation_reason' => $matched?->condition_text ?? ('Relacionado com '.$matched?->target_type.' '.$matched?->target_key.'; confirme a aplicabilidade ao seu projecto.'),
@@ -268,7 +275,11 @@ class PortalServicesController extends Controller
abort_unless($resource !== null, 404);
$resource->links = DB::table('portal_resource_links')->where('resource_id', $id)
->orderBy('target_type')->orderBy('target_key')
->get(['target_type', 'target_key', 'relationship', 'applicability', 'condition_text', 'basis_reference', 'review_status']);
->get($this->resourceLinkColumns());
$resource->links->transform(fn ($link) => (object) [...(array) $link,
'target_label' => $link->target_type === 'requirement'
? config('document-requirements.catalogue.'.$link->target_key.'.label', $link->target_key)
: null]);
$territory = $resource->territory_id
? DB::table('geo_locations')->where('id', $resource->territory_id)->value('name')
: null;
@@ -326,6 +337,10 @@ class PortalServicesController extends Controller
public function manage(MalwareScanner $scanner): Response
{
$disclosureAuthorization = DB::table('audit_events')->where('source_system', 'user-instruction')
->where('external_id', 'document-repository-disclosure-authorization-2026-10-06-v1')
->where('action', 'public_disclosure_authorized')->first(['external_id', 'occurred_at', 'after_data']);
$disclosureAuthorizationData = $disclosureAuthorization ? json_decode($disclosureAuthorization->after_data, true) : null;
$resources = DB::table('portal_resources as r')->join('documents as d', 'd.id', '=', 'r.document_id')->orderByDesc('r.updated_at')->limit(200)
->get(['r.*', 'd.file_name', 'd.media_type', 'd.size_bytes', 'd.issuer', 'd.status as file_status', 'd.data_classification', 'd.version_no as file_version', 'd.rights_status as file_rights_status', 'd.malware_scan_status', 'd.malware_failure_reason']);
$links = DB::table('portal_resource_links')->whereIn('resource_id', $resources->pluck('id'))->orderBy('id')->get()->groupBy('resource_id');
@@ -337,11 +352,20 @@ class PortalServicesController extends Controller
$malwareHealth['signatures_at'] = $malwareHealth['signatures_at']?->format(DATE_ATOM);
return Inertia::render('portal/manage', ['resources' => $resources,
'requirementCatalogue' => collect(config('document-requirements.catalogue'))->map(fn ($item, $key) => ['key' => $key, ...$item])->values(),
'requirementCatalogue' => $this->requirementEvidenceEnabled()
? collect(config('document-requirements.catalogue'))->map(fn ($item, $key) => ['key' => $key, ...$item])->values()
: collect(),
'requirementAssociationsEnabled' => $this->requirementEvidenceEnabled(),
'partnerships' => DB::table('portal_partnerships')->latest()->limit(100)->get(),
'documents' => DB::table('documents')->where('status', '!=', 'deleted')->whereNull('deleted_at')->latest()->limit(250)
->get(['id', 'file_name', 'media_type', 'size_bytes', 'issuer', 'issued_on', 'valid_until', 'uploaded_by', 'created_at', 'status', 'data_classification', 'version_no', 'sha256', 'rights_status', 'malware_scan_status', 'malware_scanned_at', 'malware_failure_reason', 'malware_engine_version', 'malware_signatures_at', 'malware_scan_attempts']),
'malwareHealth' => $malwareHealth,
'disclosureAuthorization' => $disclosureAuthorizationData ? [
'external_id' => $disclosureAuthorization->external_id,
'occurred_at' => $disclosureAuthorization->occurred_at,
'resources' => DB::table('portal_resources')->whereIn('id', $disclosureAuthorizationData['resource_ids'] ?? [])
->orderBy('id')->get(['id', 'title', 'published', 'rights_status']),
] : null,
'malwareCounts' => DB::table('documents')->whereNull('deleted_at')->where('status', '!=', 'deleted')
->select('malware_scan_status', DB::raw('count(*) as total'))->groupBy('malware_scan_status')->pluck('total', 'malware_scan_status'),
'territories' => DB::table('geo_locations')->where('data_classification', 'public')->whereNull('deleted_at')
@@ -355,12 +379,12 @@ class PortalServicesController extends Controller
public function upload(Request $request): RedirectResponse
{
$request->validate(['file' => ['required', 'file', 'mimetypes:application/pdf,text/plain,text/csv,image/jpeg,image/png,image/webp,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,application/vnd.oasis.opendocument.text', 'max:10240'],
$this->validateManagement($request, ['file' => ['required', 'file', 'mimetypes:application/pdf,text/plain,text/csv,image/jpeg,image/png,image/webp,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,application/vnd.oasis.opendocument.text', 'max:10240'],
'original_source' => ['nullable', 'url:https', 'max:2000'], 'issuer' => ['nullable', 'string', 'max:180']]);
$file = $request->file('file');
$checksum = hash_file('sha256', $file->getRealPath());
if (DB::table('documents')->where('sha256', $checksum)->whereNull('deleted_at')->exists()) {
return back()->withErrors(['file' => 'Já existe um ficheiro com o mesmo conteúdo. Verifique se corresponde a uma nova versão ou a um duplicado.']);
return to_route('services.manage')->withErrors(['file' => 'Já existe um ficheiro com o mesmo conteúdo. Verifique se corresponde a uma nova versão ou a um duplicado.']);
}
$id = (string) Str::uuid();
$key = $file->storeAs('portal-documents', $id, 'local');
@@ -385,7 +409,7 @@ class PortalServicesController extends Controller
ScanDocumentForMalware::dispatch($id)->afterCommit();
return back()->with('success', 'Documento recebido em armazenamento privado e quarentena. Não aparece na biblioteca pública até validação e publicação explícitas.');
return to_route('services.manage')->with('success', 'Documento recebido em armazenamento privado e quarentena. Não aparece na biblioteca pública até validação e publicação explícitas.');
}
public function resource(Request $request): RedirectResponse
@@ -412,7 +436,7 @@ class PortalServicesController extends Controller
return $id;
});
return back()->with('success', 'Documento registado como rascunho privado da biblioteca. A publicação requer uma acção separada.');
return to_route('services.manage')->with('success', 'Documento registado como rascunho privado da biblioteca. A publicação requer uma acção separada.');
}
public function updateResource(Request $request, int $id): RedirectResponse
@@ -448,12 +472,12 @@ class PortalServicesController extends Controller
$this->audit($request, 'resource_metadata_updated', 'portal_resources', $id);
});
return back()->with('success', 'Metadados e relações actualizados; o estado de publicação foi preservado.');
return to_route('services.manage')->with('success', 'Metadados e relações actualizados; o estado de publicação foi preservado.');
}
public function moderate(Request $request, string $type, int $id): RedirectResponse
{
$data = $request->validate(['published' => ['required', 'boolean']]);
$data = $this->validateManagement($request, ['published' => ['required', 'boolean']]);
$table = $type === 'resources' ? 'portal_resources' : 'portal_partnerships';
DB::transaction(function () use ($request, $data, $table, $type, $id) {
abort_unless(DB::table($table)->where('id', $id)->lockForUpdate()->first() !== null, 404);
@@ -462,18 +486,22 @@ class PortalServicesController extends Controller
->where('r.id', $id)->first(['r.document_type', 'r.legislation', 'r.source_confirmation', 'r.legal_status', 'r.rights_status',
'r.source_url', 'd.status', 'd.data_classification', 'd.deleted_at', 'd.rights_status as file_rights_status',
'd.malware_scan_status', 'd.malware_scanned_sha256', 'd.sha256']);
abort_unless($resource && $resource->status === 'active' && $resource->data_classification === 'public' && $resource->deleted_at === null,
422, 'O ficheiro continua privado/em quarentena e não pode ser publicado.');
abort_unless($resource->rights_status === 'authorized' && $resource->file_rights_status === 'authorized',
422, 'Confirme e registe os direitos de utilização antes de publicar.');
abort_unless($resource->source_confirmation !== 'unconfirmed' && $resource->source_url,
422, 'Identifique e confirme a fonte documental antes de publicar.');
abort_unless($resource->malware_scan_status === 'clean'
&& hash_equals(trim($resource->sha256), trim((string) $resource->malware_scanned_sha256)), 422,
'A análise antimalware tem de terminar antes da publicação.');
if ($resource->document_type === 'legislation' || (bool) $resource->legislation) {
abort_unless($resource->source_confirmation === 'official_confirmed',
422, 'Diplomas exigem confirmação da fonte oficial. Se a vigência não estiver esclarecida, publique-a como por confirmar.');
abort_unless($resource !== null && $resource->deleted_at === null, 404);
if ($resource->status !== 'active') {
throw $this->managementValidationException(['published' => 'A análise técnica ainda não libertou o ficheiro da quarentena. Aguarde a análise antimalware antes de publicar.']);
}
if ($resource->data_classification !== 'public') {
throw $this->managementValidationException(['published' => 'O ficheiro já saiu da quarentena técnica, mas continua classificado como interno. Reveja os direitos e a possibilidade de divulgação; só então altere explicitamente a classificação para pública.']);
}
if ($resource->rights_status !== 'authorized' || $resource->file_rights_status !== 'authorized') {
throw $this->managementValidationException(['published' => 'Confirme e registe os direitos de utilização antes de publicar.']);
}
if ($resource->source_confirmation === 'unconfirmed' || ! $resource->source_url) {
throw $this->managementValidationException(['published' => 'Identifique e confirme a fonte documental antes de publicar.']);
}
if ($resource->malware_scan_status !== 'clean'
|| ! hash_equals(trim($resource->sha256), trim((string) $resource->malware_scanned_sha256))) {
throw $this->managementValidationException(['published' => 'A análise antimalware tem de terminar antes da publicação.']);
}
}
DB::table($table)->where('id', $id)->update($type === 'resources'
@@ -487,18 +515,128 @@ class PortalServicesController extends Controller
$this->audit($request, $data['published'] ? 'published' : 'withdrawn', $table, $id);
});
return back()->with('success', 'Publicação actualizada.');
return to_route('services.manage')->with('success', 'Publicação actualizada.');
}
private function audit(Request $request, string $action, string $table, int $id): void
public function applyDocumentDisclosureAuthorization(Request $request): RedirectResponse
{
$event = DB::table('audit_events')->where('source_system', 'user-instruction')
->where('external_id', 'document-repository-disclosure-authorization-2026-10-06-v1')
->where('action', 'public_disclosure_authorized')->first();
abort_unless($event !== null, 404);
$authorization = json_decode($event->after_data, true);
$resourceIds = array_values(array_unique(array_map('intval', $authorization['resource_ids'] ?? [])));
abort_unless(count($resourceIds) === 4, 409, 'O âmbito da autorização registada foi alterado; interrompida a publicação.');
$published = [];
$skipped = [];
DB::transaction(function () use ($request, $event, $resourceIds, &$published, &$skipped): void {
foreach ($resourceIds as $resourceId) {
$resource = DB::table('portal_resources as r')->join('documents as d', 'd.id', '=', 'r.document_id')
->where('r.id', $resourceId)->lockForUpdate()->first([
'r.id', 'r.title', 'r.document_type', 'r.legislation', 'r.source_url', 'r.source_confirmation',
'r.rights_status as resource_rights', 'r.published', 'r.approved_by', 'r.metadata_version',
'd.id as document_id', 'd.storage_key', 'd.sha256', 'd.status', 'd.deleted_at', 'd.rights_status as file_rights',
'd.malware_scan_status', 'd.malware_scanned_sha256', 'd.data_classification',
]);
if ($resource === null) {
$skipped[$resourceId] = 'Registo ou ficheiro inexistente.';
continue;
}
if ($resource->published && $resource->data_classification === 'public' && $resource->resource_rights === 'authorized' && $resource->file_rights === 'authorized') {
$published[$resourceId] = 'Já publicado.';
continue;
}
$path = Storage::disk('local')->path($resource->storage_key);
$root = realpath(Storage::disk('local')->path(''));
$realPath = realpath($path);
$actualHash = $realPath && is_file($realPath) && $root
&& str_starts_with(strtolower($realPath), strtolower($root.DIRECTORY_SEPARATOR))
? hash_file('sha256', $realPath) : false;
if ($resource->deleted_at !== null || $resource->status !== 'active'
|| $resource->malware_scan_status !== 'clean' || ! is_string($actualHash)
|| ! hash_equals((string) $resource->sha256, $actualHash)
|| ! hash_equals((string) $resource->sha256, (string) $resource->malware_scanned_sha256)) {
$skipped[$resourceId] = 'Ficheiro indisponível, não limpo ou diferente dos bytes analisados.';
continue;
}
if (in_array($resource->resource_rights, ['restricted'], true) || in_array($resource->file_rights, ['restricted'], true)
|| $resource->source_confirmation === 'unconfirmed' || ! filter_var($resource->source_url, FILTER_VALIDATE_URL)
|| parse_url($resource->source_url, PHP_URL_SCHEME) !== 'https') {
$skipped[$resourceId] = 'Restrição de divulgação ou origem identificada em falta.';
continue;
}
$now = now();
$nextVersion = (int) $resource->metadata_version + 1;
DB::table('documents')->where('id', $resource->document_id)->update([
'rights_status' => 'authorized', 'data_classification' => 'public', 'updated_at' => $now,
]);
DB::table('portal_resources')->where('id', $resourceId)->update([
'rights_status' => 'authorized', 'published' => true, 'approved_by' => $request->user()->id,
'last_reviewed_by' => $request->user()->id, 'last_reviewed_at' => $now,
'metadata_version' => $nextVersion, 'updated_at' => $now,
]);
$this->attachAuthorizedDocumentaryEvidence($resourceId, $resource->title);
$this->recordResourceRevision($resourceId, 'published', $request->user()->id, $nextVersion);
$this->audit($request, 'published_under_user_authorization', 'portal_resources', $resourceId, [
'authorization_source_system' => $event->source_system,
'authorization_external_id' => $event->external_id,
'source_confirmation' => $resource->source_confirmation,
'legal_status_preserved' => true,
]);
$published[$resourceId] = 'Publicado com a situação de fonte e vigência já registada.';
}
});
return to_route('services.manage')->with('success', count($published).' documento(s) disponibilizado(s); '.count($skipped).' pendência(s) individual(is).')
->with('disclosureResults', ['published' => $published, 'skipped' => $skipped]);
}
private function audit(Request $request, string $action, string $table, int $id, array $additional = []): void
{
DB::table('audit_events')->insert(['source_system' => 'portal-services', 'entity_type' => $table,
'actor_user_id' => $request->user()->id, 'action' => $action, 'after_data' => json_encode(['record_id' => $id], JSON_THROW_ON_ERROR)]);
'actor_user_id' => $request->user()->id, 'action' => $action,
'after_data' => json_encode(['record_id' => $id, ...$additional], JSON_THROW_ON_ERROR)]);
}
private function attachAuthorizedDocumentaryEvidence(int $resourceId, string $title): void
{
if (! $this->requirementEvidenceEnabled()) {
return;
}
$evidence = match (true) {
str_contains($title, 'Lei n.º 22/2022') => [['fiscal.iva.lei_22_2022', 'confirmed', 'Lei n.º 22/2022, art. 17(1); citação conferida no exemplar cujo SHA-256 coincide com o ficheiro oficial. Isto é evidência documental, não valida vigência ou cálculo.']],
str_contains($title, 'BR 248/2025') || str_contains($title, 'n.º 248/2025') => array_map(fn (array $law) => ['fiscal.'.$law[0].'.'.$law[1], 'pending', 'BR n.º 248/2025, Suplemento n.º 1, '.$law[2].'; artigo/dispositivo específico não conferido nesta associação. Evidência identificada, não fundamento confirmado.'], [
['ice', 'lei_7_2025', 'Lei n.º 7/2025'], ['customs', 'lei_8_2025', 'Lei n.º 8/2025'],
['ispc', 'lei_9_2025', 'Lei n.º 9/2025'], ['iva', 'lei_10_2025', 'Lei n.º 10/2025'],
['irps', 'lei_11_2025', 'Lei n.º 11/2025'], ['irpc', 'lei_12_2025', 'Lei n.º 12/2025'],
]),
str_contains($title, 'Lei n.º 13/2016') || str_contains($title, 'BR n.º 89') => [['fiscal.iva.lei_13_2016_rectification_2017', 'pending', 'BR n.º 89/2017, I Série, p. 569, aviso relativo à Lei n.º 13/2016; a correspondência do exemplar local e os dispositivos rectificados aguardam conferência. Evidência identificada, não fundamento confirmado.']],
default => [],
};
foreach ($evidence as [$key, $reviewStatus, $basis]) {
if (! array_key_exists($key, config('document-requirements.catalogue', []))) {
continue;
}
$exists = DB::table('portal_resource_links')->where('resource_id', $resourceId)
->where('target_type', 'requirement')->where('target_key', $key)->exists();
if ($exists) {
continue;
}
DB::table('portal_resource_links')->insert([
'resource_id' => $resourceId, 'target_type' => 'requirement', 'target_key' => $key,
'relationship' => 'cites', 'applicability' => 'evidence', 'basis_reference' => $basis,
'review_status' => $reviewStatus, 'created_at' => now(), 'updated_at' => now(),
]);
}
}
private function validateResource(Request $request): array
{
$data = $request->validate([
$data = $this->validateManagement($request, [
'document_id' => ['required', 'uuid'], 'title' => ['required', 'string', 'max:200'],
'description' => ['nullable', 'string', 'max:4000'], 'category' => ['required', 'string', 'max:80'],
'topic' => ['required', 'string', 'max:80'], 'document_type' => ['required', 'in:legislation,procedure,official_form,digital_adaptation,guidance_template,guide,study,reference_data,unknown'],
@@ -515,7 +653,7 @@ class PortalServicesController extends Controller
'data_classification' => ['nullable', 'in:internal,public'],
'source_reference' => ['required', 'string', 'min:10', 'max:2000'],
'legislation' => ['required', 'boolean'], 'essential' => ['required', 'boolean'],
'associations' => ['nullable', 'array', 'max:40'], 'associations.*.target_type' => ['required', 'in:theme,module,activity,stage,procedure,entity,territory,document,investment_type,legal_source,requirement'],
'associations' => ['nullable', 'array', 'max:40'], 'associations.*.target_type' => ['required', 'in:theme,module,activity,stage,procedure,entity,territory,document,investment_type,legal_source'.($this->requirementEvidenceEnabled() ? ',requirement' : '')],
'associations.*.target_key' => ['required', 'string', 'max:180'],
'associations.*.review_status' => ['nullable', 'in:pending,confirmed'],
'associations.*.relationship' => ['required', 'in:applies_to,supports,version_of,translation_of,amends,repeals,implements,cites,related_to'],
@@ -523,24 +661,47 @@ class PortalServicesController extends Controller
'associations.*.condition_text' => ['nullable', 'string', 'max:2000'], 'associations.*.basis_reference' => ['nullable', 'string', 'max:2000'],
]);
if (! empty($data['applicable_from']) && ! empty($data['applicable_until']) && $data['applicable_until'] < $data['applicable_from']) {
throw ValidationException::withMessages(['applicable_until' => 'A data final deve ser igual ou posterior à data inicial.']);
throw $this->managementValidationException(['applicable_until' => 'A data final deve ser igual ou posterior à data inicial.']);
}
foreach ($data['associations'] ?? [] as $index => $association) {
if (($association['target_type'] ?? null) === 'requirement' && ! $this->requirementEvidenceEnabled()) {
throw $this->managementValidationException(["associations.$index.target_type" => 'As associações documentais a requisitos ainda aguardam a migração do catálogo.']);
}
if (($association['target_type'] ?? null) === 'requirement'
&& ! array_key_exists($association['target_key'], config('document-requirements.catalogue', []))) {
throw ValidationException::withMessages(["associations.$index.target_key" => 'Seleccione um identificador técnico de requisito existente.']);
throw $this->managementValidationException(["associations.$index.target_key" => 'Seleccione um identificador técnico de requisito existente.']);
}
if (mb_strlen(trim($association['basis_reference'] ?? '')) < 5) {
throw ValidationException::withMessages(["associations.$index.basis_reference" => 'Indique o artigo, secção, página ou outro trecho que esta associação sustenta.']);
throw $this->managementValidationException(["associations.$index.basis_reference" => 'Indique o artigo, secção, página ou outro trecho que esta associação sustenta.']);
}
if ($association['applicability'] === 'conditional' && mb_strlen(trim($association['condition_text'] ?? '')) < 5) {
throw ValidationException::withMessages(["associations.$index.condition_text" => 'Descreva a condição de aplicação.']);
throw $this->managementValidationException(["associations.$index.condition_text" => 'Descreva a condição de aplicação.']);
}
}
return $data;
}
/** Keep validation redirects on the management GET page even if the request
* arrived with a stale Referer pointing at a write-only resource endpoint.
*/
private function validateManagement(Request $request, array $rules): array
{
try {
return $request->validate($rules);
} catch (ValidationException $exception) {
throw $this->managementValidationException($exception->errors());
}
}
private function managementValidationException(array $errors): ValidationException
{
$exception = ValidationException::withMessages($errors);
$exception->redirectTo = route('services.manage');
return $exception;
}
private function resourceFields(array $data, int $actor, ?string $identity, ?int $duplicate, bool $creating): array
{
unset($data['associations'], $data['issuer'], $data['data_classification']);
@@ -567,7 +728,7 @@ class PortalServicesController extends Controller
'resource_id' => $resourceId, 'target_type' => $link['target_type'], 'target_key' => $link['target_key'],
'relationship' => $link['relationship'], 'applicability' => $link['applicability'],
'condition_text' => $link['condition_text'] ?? null, 'basis_reference' => $link['basis_reference'] ?? null,
'review_status' => $link['review_status'] ?? 'pending',
...($this->requirementEvidenceEnabled() ? ['review_status' => $link['review_status'] ?? 'pending'] : []),
'created_at' => now(), 'updated_at' => now(),
]);
}
@@ -586,10 +747,6 @@ class PortalServicesController extends Controller
'Registe a autorização de utilização antes de classificar o ficheiro como público.');
abort_unless($data['source_confirmation'] !== 'unconfirmed' && ! empty($data['source_url']), 422,
'Identifique e confirme a fonte antes de classificar o ficheiro como público.');
if (($data['legislation'] ?? false) || ($data['document_type'] ?? null) === 'legislation') {
abort_unless($data['source_confirmation'] === 'official_confirmed', 422,
'Diplomas exigem confirmação da fonte oficial antes de serem classificados como públicos.');
}
}
private function identityKey(array $data): ?string
@@ -611,6 +768,23 @@ class PortalServicesController extends Controller
'actor_user_id' => $actor, 'created_at' => now()]);
}
private function requirementEvidenceEnabled(): bool
{
return Schema::hasColumn('portal_resource_links', 'review_status')
&& Schema::hasTable('migrations')
&& DB::table('migrations')->where('migration', '2026_10_06_200000_add_document_requirement_evidence_links')->exists();
}
private function resourceLinkColumns(): array
{
$columns = ['target_type', 'target_key', 'relationship', 'applicability', 'condition_text', 'basis_reference'];
if (Schema::hasColumn('portal_resource_links', 'review_status')) {
$columns[] = 'review_status';
}
return $columns;
}
private function territoryScopeIds(string $id): array
{
return collect(DB::select(<<<'SQL'