This commit is contained in:
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user