Files
Investidor-V2/app/Http/Controllers/PortalServicesController.php
T
2026-10-22 20:55:08 +02:00

870 lines
61 KiB
PHP

<?php
namespace App\Http\Controllers;
use App\Jobs\ScanDocumentForMalware;
use App\Models\InterestRequest;
use App\Models\Sector;
use App\Services\CountryCatalogue;
use App\Services\IdempotencyConflict;
use App\Services\InterestSubmission;
use App\Services\LegacyContent;
use App\Services\PortalProfileAccess;
use App\Services\Malware\MalwareScanner;
use App\Services\VerifiedDocumentBinary;
use App\Services\VirtualDesk;
use Illuminate\Database\Query\Builder;
use Illuminate\Database\QueryException;
use Illuminate\Http\JsonResponse;
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;
use Inertia\Inertia;
use Inertia\Response;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
class PortalServicesController extends Controller
{
public function home(): Response
{
try {
$essentialResources = $this->resources()->where('r.essential', true)
->orderBy('r.title')->limit(6)->get(['r.id', 'r.title']);
} catch (QueryException $exception) {
// A catalogue schema mismatch must not take down unrelated home content.
// Keep the document list empty and retain the full exception in internal logs.
report($exception);
$essentialResources = collect();
}
return Inertia::render('PortalInvestidor', ['essentialResources' => $essentialResources, 'countries' => app(CountryCatalogue::class)->all()]);
}
private function resources(): Builder
{
return DB::table('portal_resources as r')->join('documents as d', 'd.id', '=', 'r.document_id')
->where('r.published', true)->where('r.rights_status', '!=', 'restricted')->where('d.rights_status', '!=', 'restricted')
->where('d.status', 'active')->where('d.data_classification', 'public')->whereNull('d.deleted_at')
->where('d.malware_scan_status', 'clean')->whereColumn('d.malware_scanned_sha256', 'd.sha256');
}
public function page(Request $request): Response|JsonResponse|RedirectResponse
{
$page = $request->route()->defaults['page'];
if ($page === 'partnerships' && Schema::hasColumn('portal_partnerships', 'partnership_type')) {
return app(PartnershipExchangeController::class)->index($request);
}
$filters = $request->validate([
'q' => ['nullable', 'string', 'max:120'], 'category' => ['nullable', 'string', 'max:80'],
'topic' => ['nullable', 'string', 'max:80'], 'sector' => ['nullable', 'string', 'max:150'], 'province' => ['nullable', 'string', 'max:100'],
'document_type' => ['nullable', 'string', 'max:40'], 'language' => ['nullable', 'string', 'max:12'],
'issuer' => ['nullable', 'string', 'max:180'], 'legal_status' => ['nullable', 'string', 'max:24'],
'source_confirmation' => ['nullable', 'string', 'max:24'], 'territory_id' => ['nullable', 'uuid'],
'activity' => ['nullable', 'array', 'max:10'], 'activity.*' => ['string', 'max:180'],
'stage' => ['nullable', 'array', 'max:10'], 'stage.*' => ['string', 'max:80'],
'module' => ['nullable', 'array', 'max:10'], 'module.*' => ['string', 'max:80'],
]);
$props = ['section' => $page, 'filters' => $filters, 'countries' => app(CountryCatalogue::class)->all()];
if ($page === 'sectors') {
$props['sectors'] = \App\Services\SectorOrganization::catalog();
}
if (in_array($page, ['documents', 'legislation'], true)) {
$query = $this->resources();
if ($page === 'legislation') {
$query->where('r.legislation', true);
}
$props['categories'] = (clone $query)->distinct()->orderBy('r.category')->pluck('r.category');
$props['topics'] = (clone $query)->distinct()->orderBy('r.topic')->pluck('r.topic');
foreach (['category', 'topic', 'document_type', 'language', 'legal_status', 'source_confirmation'] as $field) {
if (! empty($filters[$field])) {
$query->where('r.'.$field, $filters[$field]);
}
}
if (! empty($filters['issuer'])) {
$query->where('d.issuer', 'ilike', '%'.$filters['issuer'].'%');
}
if (! empty($filters['territory_id'])) {
$territoryIds = $this->territoryScopeIds($filters['territory_id']);
$query->where(function (Builder $territorial) use ($territoryIds): void {
$territorial->where('r.territorial_scope', 'national')->orWhereIn('r.territory_id', $territoryIds)
->orWhereExists(fn ($link) => $link->selectRaw('1')->from('portal_resource_links as l')
->whereColumn('l.resource_id', 'r.id')->where('l.target_type', 'territory')->whereIn('l.target_key', $territoryIds));
});
}
foreach (['activity', 'stage', 'module'] as $dimension) {
if (! empty($filters[$dimension])) {
$query->whereExists(fn ($link) => $link->selectRaw('1')->from('portal_resource_links as l')
->whereColumn('l.resource_id', 'r.id')->where('l.target_type', $dimension)
->whereIn('l.target_key', array_values($filters[$dimension])));
}
}
if (! empty($filters['q'])) {
$term = '%'.$filters['q'].'%';
$query->where(fn (Builder $search) => $search->where('r.title', 'ilike', $term)->orWhere('r.description', 'ilike', $term)
->orWhere('r.document_number', 'ilike', $term)->orWhere('d.issuer', 'ilike', $term)
->orWhere('r.source_reference', 'ilike', $term)->orWhere('r.category', 'ilike', $term)->orWhere('r.topic', 'ilike', $term));
}
$pageResult = $query->orderByRaw("CASE WHEN r.document_type IN ('official_form','digital_adaptation','guidance_template') THEN 1 ELSE 0 END")
->orderByDesc('r.essential')->orderBy('r.title')->select([
'r.id', 'r.document_id', 'r.title', 'r.description', 'r.category', 'r.topic', 'r.document_type', 'r.document_number', 'r.document_date',
'r.applicable_from', 'r.applicable_until',
'r.source_url', 'r.language', 'r.version_label', 'r.document_group_id', 'r.territorial_scope', 'r.territory_id',
'r.source_confirmation', 'r.legal_status', 'r.rights_status', 'r.last_reviewed_at', 'r.last_reviewed_by', 'r.independent_review', 'r.metadata_version', 'r.source_reference', 'r.essential',
'd.file_name', 'd.media_type', 'd.size_bytes', 'd.issuer', 'd.issued_on', 'd.valid_until', 'd.version_no',
])->paginate(20)->withQueryString();
$pageResult->getCollection()->transform(fn ($resource) => app(\App\Services\PublicDocumentPresentation::class)->resource($resource));
$props['resources'] = $pageResult;
$props['documentTypes'] = (clone $this->resources())->distinct()->orderBy('r.document_type')->pluck('r.document_type');
$props['languages'] = (clone $this->resources())->distinct()->orderBy('r.language')->pluck('r.language');
$props['issuers'] = (clone $this->resources())->whereNotNull('d.issuer')->distinct()->orderBy('d.issuer')->pluck('d.issuer');
$props['legalStatuses'] = (clone $this->resources())->distinct()->orderBy('r.legal_status')->pluck('r.legal_status');
$props['territories'] = DB::table('geo_locations')->where('data_classification', 'public')->whereNull('deleted_at')
->whereIn('location_type', ['province', 'district', 'municipality', 'site'])->orderBy('name')->get(['id', 'name', 'location_type', 'parent_id']);
foreach (['activities' => 'activity', 'stages' => 'stage', 'modules' => 'module'] as $key => $type) {
$props[$key] = DB::table('portal_resource_links as l')->join('portal_resources as r', 'r.id', '=', 'l.resource_id')
->join('documents as d', 'd.id', '=', 'r.document_id')->where('l.target_type', $type)->where('r.published', true)
->where('r.rights_status', '!=', 'restricted')->where('d.rights_status', '!=', 'restricted')
->where('d.status', 'active')->where('d.data_classification', 'public')->whereNull('d.deleted_at')
->where('d.malware_scan_status', 'clean')->whereColumn('d.malware_scanned_sha256', 'd.sha256')
->distinct()->orderBy('l.target_key')->pluck('l.target_key');
}
}
if ($page === 'partnerships') {
$query = DB::table('portal_partnerships')->where('status', 'published');
foreach (['sector', 'province'] as $field) {
if (! empty($filters[$field])) {
$query->where($field, $filters[$field]);
}
}
if (! empty($filters['q'])) {
$query->where('title', 'ilike', '%'.$filters['q'].'%');
}
$props['partnerships'] = $query->orderByDesc('id')->select(['id', 'title', 'sector', 'province', 'offer', 'seeking'])->paginate(20)->withQueryString();
}
if ($page === 'desk') {
if (app(VirtualDesk::class)->installed()) {
return redirect('/balcao-virtual/painel');
}
$props['requests'] = InterestRequest::where('metadata->portal_user_id', $request->user()->id)->latest()->limit(100)->get(['reference', 'status', 'created_at', 'metadata']);
$props['myPartnerships'] = DB::table('portal_partnerships')->where('user_id', $request->user()->id)->latest()->limit(100)->get(['id', 'title', 'status']);
}
$props['contacts'] = config('portal.support');
return Inertia::render('portal/services', $props);
}
public function recommendations(Request $request): JsonResponse
{
$data = $request->validate([
'activity_id' => ['nullable', 'string', 'max:180'], 'province_id' => ['nullable', 'uuid'],
'stage' => ['nullable', 'string', 'max:80'], 'module' => ['nullable', 'string', 'max:80'],
]);
$module = $data['module'] ?? 'simulator';
$query = $this->resources()->whereExists(fn ($link) => $link->selectRaw('1')->from('portal_resource_links as l')
->whereColumn('l.resource_id', 'r.id')->where('l.target_type', 'module')->where('l.target_key', $module));
$matches = [['type' => 'module', 'key' => $module]];
foreach (['activity_id' => 'activity', 'stage' => 'stage'] as $input => $type) {
if (! empty($data[$input])) {
$key = $data[$input];
$query->where(function (Builder $contextual) use ($type, $key): void {
$contextual->whereNotExists(fn ($link) => $link->selectRaw('1')->from('portal_resource_links as general')
->whereColumn('general.resource_id', 'r.id')->where('general.target_type', $type))
->orWhereExists(fn ($link) => $link->selectRaw('1')->from('portal_resource_links as matching')
->whereColumn('matching.resource_id', 'r.id')->where('matching.target_type', $type)->where('matching.target_key', $key));
});
$matches[] = ['type' => $type, 'key' => $data[$input]];
}
}
if (! empty($data['province_id'])) {
$ids = $this->territoryScopeIds($data['province_id']);
$query->where(fn (Builder $territorial) => $territorial->where('r.territorial_scope', 'national')->orWhereIn('r.territory_id', $ids)
->orWhereExists(fn ($link) => $link->selectRaw('1')->from('portal_resource_links as l')->whereColumn('l.resource_id', 'r.id')
->where('l.target_type', 'territory')->whereIn('l.target_key', $ids)));
$matches = [...$matches, ...array_map(fn ($id) => ['type' => 'territory', 'key' => $id], $ids)];
}
$resources = $query->orderByDesc('r.essential')->orderBy('r.title')->limit(10)
->get(['r.id', 'r.title', 'r.description', 'r.document_type', 'r.document_date', 'r.document_number', 'r.applicable_from', 'r.applicable_until', 'r.language', 'r.territorial_scope', 'r.legal_status', 'r.source_confirmation', 'r.source_url', 'd.issuer', 'd.media_type', 'd.size_bytes']);
$resources->transform(fn ($resource) => app(\App\Services\PublicDocumentPresentation::class)->resource($resource));
return response()->json(['data' => $resources]);
}
public function submit(Request $request, InterestSubmission $submission): RedirectResponse
{
$data = $request->validate([
'kind' => ['required', 'in:support,investor,company,partnership'],
'fullName' => ['required', 'string', 'min:3', 'max:150'], 'email' => ['required', 'email:rfc', 'max:150'],
'phone' => ['nullable', 'string', 'max:30'], 'country' => ['required', 'string', 'max:100', app(CountryCatalogue::class)->legacyCountryRule()],
'declared_origin_country_code' => ['nullable', app(CountryCatalogue::class)->rule()],
'company' => ['nullable', 'required_if:kind,company', 'string', 'max:150'],
'sectorInterest' => ['required', 'string', 'max:150'], 'message' => ['required', 'string', 'min:20', 'max:2000'],
'privacyConsent' => ['required', 'accepted'], 'submission_key' => ['required', 'uuid'],
'partnership_id' => ['nullable', 'integer'],
]);
if (! empty($data['partnership_id'])) {
abort_unless(DB::table('portal_partnerships')->where('id', $data['partnership_id'])->where('status', 'published')->exists(), 422);
}
$data['portal_user_id'] = $request->user()?->id;
// Preserve the actual entry form, not only the shared POST endpoint. Never accept an external or administrative referrer as the entry page.
$entry = parse_url($request->headers->get('referer', ''));
$sourcePage = '/apoio';
if (is_array($entry) && strtolower($entry['scheme'] ?? '') === $request->getScheme()
&& strtolower($entry['host'] ?? '') === strtolower($request->getHost())
&& (int) ($entry['port'] ?? (($entry['scheme'] ?? '') === 'https' ? 443 : 80)) === $request->getPort()
&& in_array($entry['path'] ?? '', ['/apoio', '/formularios/investidor', '/formularios/empresa', '/parcerias', '/parcerias/painel'], true)) {
$sourcePage = $entry['path'];
}
try {
$interest = $submission->receive($data, $data['submission_key'], fn () => InterestRequest::create([
'reference' => 'AP-'.now()->year.'-'.Str::ulid(), 'full_name' => $data['fullName'], 'email' => $data['email'],
'phone' => $data['phone'] ?? null, 'country' => $data['country'],
'investor_type' => $data['kind'] === 'company' ? 'empresa' : 'individual',
'sector_interest' => $data['sectorInterest'], 'message' => $data['message'],
'privacy_consent' => true, 'consented_at' => now(), 'status' => 'received', 'source_page' => $sourcePage,
'metadata' => ['kind' => $data['kind'], 'company' => $data['company'] ?? null,
'portal_user_id' => $data['portal_user_id'], 'partnership_id' => $data['partnership_id'] ?? null,
'declared_origin_country_code' => $data['declared_origin_country_code'] ?? null],
]));
} catch (IdempotencyConflict) {
abort(409, 'Este envio já foi usado com dados diferentes. Recarregue o formulário.');
}
return back()->with('success', 'Pedido registado. Protocolo: '.$interest->reference);
}
public function partnership(Request $request): RedirectResponse
{
$data = $request->validate(['title' => ['required', 'string', 'max:200'], 'sector' => ['required', 'string', 'max:150'],
'province' => ['required', 'string', 'max:100'], 'offer' => ['required', 'string', 'min:20', 'max:2000'],
'seeking' => ['required', 'string', 'min:20', 'max:2000'], 'publication_consent' => ['accepted']]);
unset($data['publication_consent']);
DB::table('portal_partnerships')->insert([...$data, 'user_id' => $request->user()->id, 'status' => 'pending', 'created_at' => now(), 'updated_at' => now()]);
return back()->with('success', 'Proposta recebida para publicação. Acompanhe no Balcão Virtual.');
}
public function download(int $id): BinaryFileResponse|\Illuminate\Http\Response
{
$resource = $this->resources()->where('r.id', $id)->first(['d.*']);
abort_unless($resource !== null, 404);
return app(VerifiedDocumentBinary::class)->respond($resource);
}
public function showDocument(Request $request, int $id): Response
{
$resource = $this->resources()->where('r.id', $id)->first([
'r.id', 'r.document_id', 'r.title', 'r.description', 'r.category', 'r.topic', 'r.document_type',
'r.document_number', 'r.document_date', 'r.applicable_from', 'r.applicable_until', 'r.source_url', 'r.language', 'r.version_label',
'r.territorial_scope', 'r.territory_id', 'r.source_confirmation', 'r.legal_status', 'r.source_reference',
'r.essential', 'd.file_name', 'd.media_type', 'd.size_bytes', 'd.issuer', 'd.issued_on', 'd.valid_until', 'd.version_no',
]);
abort_unless($resource !== null, 404);
$resource->territory_name = $resource->territory_id
? DB::table('geo_locations')->where('id', $resource->territory_id)->value('name') : null;
$resource = app(\App\Services\PublicDocumentPresentation::class)->resource($resource);
$back = $request->query('back');
$backUrl = is_string($back) && (str_starts_with($back, '/documentos') || str_starts_with($back, '/legislacao')) && ! str_starts_with($back, '//')
? $back : '/documentos';
return Inertia::render('portal/document', [
'resource' => $resource,
'backUrl' => $backUrl,
'downloadUrl' => route('services.download', ['id' => $id]),
'articleUrl' => Schema::hasColumn('portal_resources','editorial_content') && DB::table('portal_resources')->where('id',$id)->whereNotNull('editorial_content')->exists() ? '/artigos/'.$id : null,
]);
}
public function viewDocument(int $id): BinaryFileResponse|\Illuminate\Http\Response
{
$document = $this->resources()->where('r.id', $id)->first(['d.*']);
abort_unless($document && in_array($document->media_type, ['application/pdf', 'image/jpeg', 'image/png', 'image/webp'], true), 404);
return app(VerifiedDocumentBinary::class)->respond($document, true);
}
public function downloadForManager(Request $request, string $id): BinaryFileResponse|\Illuminate\Http\Response
{
$document = app(\App\Services\DocumentManagementAccess::class)->documents($request->user())->where('id', $id)->first();
abort_unless($document && $document->status === 'active' && $document->malware_scan_status === 'clean'
&& hash_equals(trim($document->sha256), trim((string) $document->malware_scanned_sha256)), 404);
return app(VerifiedDocumentBinary::class)->respond($document);
}
public function manage(Request $request, MalwareScanner $scanner): Response
{
$managementFilters = $request->validate(['q' => 'nullable|string|max:150', 'state' => 'nullable|in:draft,published,withdrawn', 'intent' => 'nullable|in:document,legislation']);
$access = app(\App\Services\DocumentManagementAccess::class);
if (! $access->full($request->user())) {
$documents = $access->documents($request->user())->where('status', '!=', 'deleted');
$health = $scanner->health();
$health['signatures_at'] = $health['signatures_at']?->format(DATE_ATOM);
$editorial = $access->editorial($request->user());
$resources = $editorial ? $this->filterManagedResources($access->editorialResources($request->user()), $managementFilters)->orderByDesc('r.updated_at')
->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']) : collect();
$links = DB::table('portal_resource_links')->whereIn('resource_id', $resources->pluck('id'))->orderBy('id')->get()->groupBy('resource_id');
$revisions = DB::table('portal_resource_revisions')->whereIn('resource_id', $resources->pluck('id'))->orderByDesc('revision_no')->get(['resource_id', 'revision_no', 'change_type', 'actor_user_id', 'created_at'])->groupBy('resource_id');
$resources->each(function ($resource) use ($links, $revisions) {
$resource->links = $links->get($resource->id, collect())->values();
$resource->revisions = $revisions->get($resource->id, collect())->values();
});
return Inertia::render('portal/manage', [
'managementFilters' => $managementFilters, 'associationOptions' => $this->contentAssociations($request),
'documentAccessOnly' => ! $editorial, 'editorialScopeOnly' => $editorial, 'resources' => $resources, 'partnerships' => [], 'territories' => [],
'requirementCatalogue' => [], 'requirementAssociationsEnabled' => false,
'disclosureAuthorization' => null, 'incompleteCount' => $resources->filter(fn ($r) => ! $r->description || ! $r->issuer || ! $r->source_url || $r->source_confirmation === 'unconfirmed' || $r->legal_status === 'unknown' || $r->rights_status === 'unconfirmed')->count(), 'possibleDuplicates' => $resources->whereNotNull('duplicate_of_resource_id')->count(),
'documents' => (clone $documents)->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',
]),
'malwareCounts' => (clone $documents)->select('malware_scan_status', DB::raw('count(*) as total'))
->groupBy('malware_scan_status')->pluck('total', 'malware_scan_status'),
'malwareHealth' => $health,
]);
}
$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 = $this->filterManagedResources(DB::table('portal_resources as r')->join('documents as d', 'd.id', '=', 'r.document_id')->whereNull('d.deleted_at'), $managementFilters)->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');
$resources->each(fn ($resource) => $resource->links = $links->get($resource->id, collect())->values());
$revisions = DB::table('portal_resource_revisions')->whereIn('resource_id', $resources->pluck('id'))->orderByDesc('revision_no')->get(['resource_id', 'revision_no', 'change_type', 'actor_user_id', 'created_at'])->groupBy('resource_id');
$resources->each(fn ($resource) => $resource->revisions = $revisions->get($resource->id, collect())->values());
$malwareHealth = $scanner->health();
$malwareHealth['signatures_at'] = $malwareHealth['signatures_at']?->format(DATE_ATOM);
return Inertia::render('portal/manage', ['resources' => $resources,
'managementFilters' => $managementFilters, 'associationOptions' => $this->contentAssociations($request),
'requirementCatalogue' => $this->requirementEvidenceEnabled()
? collect(is_array($catalogue = config('document-requirements.catalogue', [])) ? $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')
->whereIn('location_type', ['country', 'province', 'district', 'municipality', 'site'])->orderBy('name')->get(['id', 'name', 'location_type']),
'incompleteCount' => DB::table('portal_resources as r')->join('documents as d', 'd.id', '=', 'r.document_id')
->where(fn ($q) => $q->whereNull('r.description')->orWhereNull('d.issuer')->orWhereNull('r.source_url')
->orWhere('r.source_confirmation', 'unconfirmed')->orWhere('r.legal_status', 'unknown')->orWhere('r.rights_status', 'unconfirmed'))
->count(),
'possibleDuplicates' => DB::table('portal_resources')->whereNotNull('duplicate_of_resource_id')->count()]);
}
private function filterManagedResources(Builder $query, array $filters): Builder
{
if (! empty($filters['q'])) {
$term = '%'.addcslashes($filters['q'], '%_\\').'%';
$query->where(fn ($q) => $q->where('r.title', 'ilike', $term)->orWhere('r.document_number', 'ilike', $term)->orWhere('d.issuer', 'ilike', $term)->orWhere('d.file_name', 'ilike', $term));
}
if (! empty($filters['state'])) {
$query->where('r.published', $filters['state'] === 'published');
if ($filters['state'] !== 'published') {
$method = $filters['state'] === 'withdrawn' ? 'whereExists' : 'whereNotExists';
$query->$method(fn ($q) => $q->selectRaw('1')->from('portal_resource_revisions as history')->whereColumn('history.resource_id', 'r.id')->whereIn('history.change_type', ['unpublished', 'withdrawn']));
}
}
return $query;
}
private function contentAssociations(Request $request): array
{
$access = app(\App\Services\DocumentManagementAccess::class);
$resources = $access->full($request->user()) ? DB::table('portal_resources as r')->join('documents as d', 'd.id', '=', 'r.document_id')->whereNull('d.deleted_at')
: ($access->editorial($request->user()) ? $access->editorialResources($request->user()) : null);
$documents = $resources ? $resources->orderBy('r.title')->get(['r.id as value', 'r.title as label', 'r.document_type']) : collect();
return [
'sector' => \App\Models\Sector::choices()->orderBy('name')->get(['id as value', 'name as label']),
'territory' => app(\App\Services\InvestmentGeography::class)->publicReferences()->orderBy('name')->get(['id as value', 'name as label']),
'opportunity' => app(PortalProfileAccess::class)->scope(\App\Models\Opportunity::query(), $request->user())->orderBy('title')->get(['id as value', 'title as label']),
'document' => $documents,
'procedure' => $documents->where('document_type', 'procedure')->values(),
'stage' => collect(config('virtual-desk.steps', []))->map(fn ($step, $key) => ['value' => $key, 'label' => $step['title']])->values(),
'activity' => app(\App\Services\EconomicReferenceCatalog::class)->activities()->map(fn ($row) => ['value' => $row->id, 'label' => $row->display_label])->values(),
];
}
public function upload(Request $request): RedirectResponse
{
$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'.(app(LegacyContent::class)->available() ? ',application/json' : ''), '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 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');
abort_unless($key !== false, 500);
try {
DB::transaction(function () use ($file, $id, $key, $request, $checksum) {
DB::select('SELECT pg_advisory_xact_lock(hashtext(?))', ['investmoz-document-content:'.$checksum]);
abort_unless(! DB::table('documents')->where('sha256', $checksum)->whereNull('deleted_at')->exists(), 409,
'Já existe um ficheiro com o mesmo conteúdo. Verifique se corresponde a uma nova versão ou a um duplicado.');
DB::table('documents')->insert(['id' => $id, 'source_system' => 'portal-services',
'file_name' => basename($file->getClientOriginalName()), 'media_type' => $file->getMimeType(), 'storage_key' => $key,
'sha256' => $checksum, 'size_bytes' => $file->getSize(), 'issuer' => $request->input('issuer'),
'original_source' => $request->input('original_source'), 'uploaded_by' => $request->user()->id,
'status' => 'quarantined', 'data_classification' => 'internal', 'rights_status' => 'unconfirmed']);
DB::table('audit_events')->insert(['source_system' => 'portal-services', 'entity_type' => 'documents', 'entity_id' => $id,
'actor_user_id' => $request->user()->id, 'action' => 'document_uploaded_to_quarantine']);
});
} catch (\Throwable $exception) {
Storage::disk('local')->delete($key);
throw $exception;
}
ScanDocumentForMalware::dispatch($id)->afterCommit();
return to_route('services.manage', array_filter(['intent' => in_array($request->input('intent'), ['document', 'legislation'], true) ? $request->input('intent') : null]))->with('uploaded_document_id', $id)->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
{
$data = $this->validateResource($request);
$id = DB::transaction(function () use ($data, $request): int {
$document = DB::table('documents')->where('id', $data['document_id'])->where('status', '!=', 'deleted')->whereNull('deleted_at')->lockForUpdate()->first();
abort_unless($document !== null, 422, 'Seleccione um documento existente e não eliminado.');
abort_unless(app(\App\Services\DocumentManagementAccess::class)->documents($request->user())->where('id', $document->id)->exists(), 403);
$identity = $this->identityKey($data);
if ($identity) {
DB::select('SELECT pg_advisory_xact_lock(hashtext(?))', ['portal-resource-identity:'.$identity]);
}
$duplicate = $identity ? DB::table('portal_resources')->where('document_identity_key', $identity)->value('id') : null;
$fields = $this->resourceFields($data, $request->user()->id, $identity, $duplicate, true);
$this->assertPublicClassificationEligible($data, $document);
$id = DB::table('portal_resources')->insertGetId([...$fields, 'approved_by' => null, 'published' => false, 'created_at' => now(), 'updated_at' => now()]);
$this->syncResourceLinks($id, $data['associations'] ?? []);
$this->recordResourceRevision($id, 'created', $request->user()->id, 1);
DB::table('documents')->where('id', $document->id)->update(['issuer' => ($data['issuer'] ?? null) ?: $document->issuer,
'issued_on' => ($data['document_date'] ?? null) ?: $document->issued_on, 'rights_status' => $data['rights_status'],
'data_classification' => $data['data_classification'] ?? $document->data_classification, 'updated_at' => now()]);
$this->audit($request, $duplicate ? 'resource_registered_possible_duplicate' : 'resource_registered', 'portal_resources', $id);
return $id;
});
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
{
$data = $this->validateResource($request);
$expectedVersion = (int) $request->validate(['metadata_version' => ['required', 'integer', 'min:1']])['metadata_version'];
DB::transaction(function () use ($data, $request, $id, $expectedVersion): void {
$resource = DB::table('portal_resources')->where('id', $id)->lockForUpdate()->first();
abort_unless($resource !== null, 404);
app(\App\Services\DocumentManagementAccess::class)->assertEditableResource($request->user(), $id);
abort_if((int) $resource->metadata_version !== $expectedVersion, 409, 'O documento foi alterado por outra sessão. Recarregue a revisão antes de editar.');
$document = DB::table('documents')->where('id', $data['document_id'])->where('status', '!=', 'deleted')->whereNull('deleted_at')->lockForUpdate()->first();
abort_unless($document !== null, 422, 'Seleccione um documento existente e não eliminado.');
abort_unless(app(\App\Services\DocumentManagementAccess::class)->documents($request->user())->where('id', $document->id)->exists(), 403);
$identity = $this->identityKey($data);
if ($identity) {
DB::select('SELECT pg_advisory_xact_lock(hashtext(?))', ['portal-resource-identity:'.$identity]);
}
$duplicate = $identity ? DB::table('portal_resources')->where('document_identity_key', $identity)->where('id', '!=', $id)->value('id') : null;
$fields = $this->resourceFields($data, $request->user()->id, $identity, $duplicate, false);
$this->assertPublicClassificationEligible($data, $document);
$retainPublication = (bool) $resource->published && $data['rights_status'] === 'authorized'
&& $data['source_confirmation'] !== 'unconfirmed' && ! empty($data['source_url'])
&& (! ($data['legislation'] || $data['document_type'] === 'legislation') || $data['source_confirmation'] === 'official_confirmed')
&& $document->status === 'active' && ($data['data_classification'] ?? $document->data_classification) === 'public' && $document->rights_status === 'authorized'
&& $document->malware_scan_status === 'clean' && hash_equals(trim($document->sha256), trim((string) $document->malware_scanned_sha256));
DB::table('portal_resources')->where('id', $id)->update([...$fields, 'approved_by' => $retainPublication ? $resource->approved_by : null, 'published' => $retainPublication,
'metadata_version' => $expectedVersion + 1, 'updated_at' => now()]);
DB::table('portal_resource_links')->where('resource_id', $id)->delete();
$this->syncResourceLinks($id, $data['associations'] ?? []);
$this->recordResourceRevision($id, 'metadata_updated', $request->user()->id, $expectedVersion + 1);
DB::table('documents')->where('id', $document->id)->update(['issuer' => ($data['issuer'] ?? null) ?: $document->issuer,
'issued_on' => ($data['document_date'] ?? null) ?: $document->issued_on, 'rights_status' => $data['rights_status'],
'data_classification' => $data['data_classification'] ?? $document->data_classification, 'updated_at' => now()]);
$this->audit($request, 'resource_metadata_updated', 'portal_resources', $id);
});
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
{
$access = app(\App\Services\DocumentManagementAccess::class);
if (! $access->full($request->user())) {
abort_unless($type === 'resources', 403);
$access->assertEditableResource($request->user(), $id);
}
if ($type === 'partnerships' && Schema::hasColumn('portal_partnerships', 'partnership_type')) {
return redirect('/parcerias/'.$id)->with('success', 'Reveja a proposta e registe uma decisão fundamentada na ficha da Bolsa.');
}
$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);
if ($type === 'resources') app(\App\Services\DocumentManagementAccess::class)->assertEditableResource($request->user(), $id);
if ($type === 'resources' && $data['published']) {
$resource = DB::table('portal_resources as r')->join('documents as d', 'd.id', '=', 'r.document_id')
->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 !== 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'
? ['published' => $data['published'], 'approved_by' => $data['published'] ? $request->user()->id : null,
'last_reviewed_by' => $request->user()->id, 'last_reviewed_at' => now(), 'metadata_version' => DB::raw('metadata_version + 1'), 'updated_at' => now()]
: ['status' => $data['published'] ? 'published' : 'withdrawn', 'reviewed_by' => $request->user()->id, 'updated_at' => now()]);
if ($type === 'resources') {
$this->recordResourceRevision($id, $data['published'] ? 'published' : 'unpublished', $request->user()->id,
(int) DB::table('portal_resources')->where('id', $id)->value('metadata_version'));
}
$this->audit($request, $data['published'] ? 'published' : 'withdrawn', $table, $id);
});
return to_route('services.manage')->with('success', 'Publicação actualizada.');
}
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]);
}
/** @param array<string,mixed> $additional */
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, ...$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(),
]);
}
}
/** @return array<string,mixed> */
private function validateResource(Request $request): array
{
$data = $this->validateManagement($request, [
'document_id' => ['required', 'uuid'], 'title' => ['required', 'string', 'max:200'],
'description' => ['nullable', 'string', 'max:4000'], 'category' => ['required', 'string', 'max:80'],
// QA legacy originals may have no theme (e.g. a shared image). Do not invent a sector to disclose an authorized original.
'topic' => [app(LegacyContent::class)->available() ? 'nullable' : 'required', 'string', 'max:80'],
'document_type' => ['required', 'in:legislation,procedure,official_form,digital_adaptation,guidance_template,guide,study,reference_data,unknown'],
'issuer' => ['nullable', 'string', 'max:180'], 'document_number' => ['nullable', 'string', 'max:120'],
'document_date' => ['nullable', 'date_format:Y-m-d'], 'source_url' => ['nullable', 'url:https', 'max:2000'],
'applicable_from' => ['nullable', 'date_format:Y-m-d'],
'applicable_until' => ['nullable', 'date_format:Y-m-d'],
'language' => ['required', 'string', 'max:12'], 'version_label' => ['nullable', 'string', 'max:120'],
'document_group_id' => ['nullable', 'uuid'], 'territorial_scope' => ['required', 'in:national,provincial,district,locality,multiple,unknown'],
'territory_id' => ['nullable', 'uuid', 'exists:geo_locations,id'],
'source_confirmation' => ['required', 'in:unconfirmed,identified,official_confirmed'],
'legal_status' => ['required', 'in:not_applicable,in_force,historical,proposed,repealed,amended,unknown'],
'rights_status' => ['required', 'in:unconfirmed,authorized,restricted'], 'independent_review' => ['required', 'boolean'],
'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,sector,opportunity'.($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'],
'associations.*.applicability' => ['required', 'in:required,conditional,support,optional_template,evidence'],
'associations.*.condition_text' => ['nullable', 'string', 'max:2000'], 'associations.*.basis_reference' => ['nullable', 'string', 'max:2000'],
]);
$data['topic'] = $data['topic'] ?? '';
if (! empty($data['applicable_from']) && ! empty($data['applicable_until']) && $data['applicable_until'] < $data['applicable_from']) {
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 $this->managementValidationException(["associations.$index.target_key" => 'Seleccione um identificador técnico de requisito existente.']);
}
if (mb_strlen(trim($association['basis_reference'] ?? '')) < 5) {
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 $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.
*
* @param array<string,mixed> $rules
* @return array<string,mixed>
*/
private function validateManagement(Request $request, array $rules): array
{
try {
return $request->validate($rules);
} catch (ValidationException $exception) {
throw $this->managementValidationException($exception->errors());
}
}
/** @param array<string,string|list<string>> $errors */
private function managementValidationException(array $errors): ValidationException
{
$exception = ValidationException::withMessages($errors);
$exception->redirectTo = route('services.manage');
return $exception;
}
/** @param array<string,mixed> $data
* @return array<string,mixed> */
private function resourceFields(array $data, int $actor, ?string $identity, ?int $duplicate, bool $creating): array
{
unset($data['associations'], $data['issuer'], $data['data_classification']);
$data['document_identity_key'] = $identity;
$data['duplicate_of_resource_id'] = $duplicate;
$data['last_reviewed_at'] = $data['source_confirmation'] !== 'unconfirmed' || $data['rights_status'] === 'authorized' ? now() : null;
$data['last_reviewed_by'] = $actor;
if ($creating) {
$data['created_by'] = $actor;
} else {
unset($data['metadata_version']);
}
return $data;
}
/** @param list<array<string,mixed>> $links */
private function syncResourceLinks(int $resourceId, array $links): void
{
foreach ($links as $link) {
if ($link['target_type'] === 'document') {
abort_unless(DB::table('portal_resources')->where('id', $link['target_key'])->exists(), 422, 'A relação documental seleccionada não existe.');
}
DB::table('portal_resource_links')->insert([
'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,
...($this->requirementEvidenceEnabled() ? ['review_status' => $link['review_status'] ?? 'pending'] : []),
'created_at' => now(), 'updated_at' => now(),
]);
}
}
/** @param array<string,mixed> $data */
private function assertPublicClassificationEligible(array $data, \stdClass $document): void
{
if (($data['data_classification'] ?? $document->data_classification) !== 'public') {
return;
}
abort_unless($document->status === 'active' && $document->malware_scan_status === 'clean'
&& hash_equals(trim($document->sha256), trim((string) $document->malware_scanned_sha256)), 422,
'A análise antimalware e a integridade do ficheiro têm de estar confirmadas antes de o classificar como público.');
abort_unless($data['rights_status'] === 'authorized', 422,
'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.');
}
/** @param array<string,mixed> $data */
private function identityKey(array $data): ?string
{
if (empty($data['issuer']) || empty($data['document_number'])) {
return null;
}
return hash('sha256', mb_strtolower(implode('|', [trim($data['issuer']), trim($data['document_number']),
$data['document_date'] ?? '', $data['language'], $data['version_label'] ?? ''])));
}
private function recordResourceRevision(int $id, string $changeType, int $actor, int $revision): void
{
$resource = DB::table('portal_resources')->where('id', $id)->first();
$links = DB::table('portal_resource_links')->where('resource_id', $id)->orderBy('id')->get();
DB::table('portal_resource_revisions')->insert(['resource_id' => $id, 'revision_no' => $revision, 'change_type' => $changeType,
'snapshot' => json_encode(['resource' => $resource, 'associations' => $links], JSON_THROW_ON_ERROR),
'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();
}
/** @return list<string> */
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;
}
/** @return list<string> */
private function territoryScopeIds(string $id): array
{
$rows = DB::select(<<<'SQL'
WITH RECURSIVE territory_tree AS (
SELECT id FROM geo_locations WHERE id = ? AND deleted_at IS NULL AND data_classification = 'public'
UNION ALL
SELECT child.id FROM geo_locations child
INNER JOIN territory_tree parent ON child.parent_id = parent.id
WHERE child.deleted_at IS NULL AND child.data_classification = 'public'
)
SELECT id FROM territory_tree
SQL, [$id]);
$ids = [];
foreach ($rows as $row) {
if (! is_string($row->id)) {
throw new \RuntimeException('Territory identifier must be a string.');
}
$ids[] = $row->id;
}
return $ids ?: [$id];
}
}