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]); } 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]; if ($page === 'sectors') { $props['sectors'] = Sector::where('is_active', true)->withCount(['opportunities' => fn ($q) => $q->published()])->orderBy('sort_order')->get(['id', 'name', 'slug', 'short_description']); } 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(function ($resource) { $resource->links = DB::table('portal_resource_links')->where('resource_id', $resource->id) ->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; return $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.legal_status', 'r.source_confirmation', 'r.source_url', 'r.source_reference']); $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 = $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.'), 'applicability' => $matched->applicability ?? 'support', 'basis_reference' => $matched?->basis_reference, 'documentary_evidence' => $evidence->map(fn ($link) => ['key' => $link->target_key, 'label' => config('document-requirements.catalogue.'.$link->target_key.'.label', $link->target_key), 'citation' => $link->basis_reference, 'status' => 'citation_checked_application_pending'])->values()]; }); 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'], '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; 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' => '/apoio', 'metadata' => ['kind' => $data['kind'], 'company' => $data['company'] ?? null, 'portal_user_id' => $data['portal_user_id'], 'partnership_id' => $data['partnership_id'] ?? 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 { $resource = $this->resources()->where('r.id', $id)->first(['d.storage_key', 'd.file_name', 'd.sha256']); abort_unless($resource !== null, 404); $root = realpath(Storage::disk('local')->path('')); $path = realpath(Storage::disk('local')->path($resource->storage_key)); abort_unless($root && $path && str_starts_with(strtolower($path), strtolower($root.DIRECTORY_SEPARATOR)) && is_file($path), 404); $checksum = hash_file('sha256', $path); abort_unless($checksum !== false && hash_equals($resource->sha256, $checksum), 404); return response()->download($path, basename($resource->file_name), ['X-Content-Type-Options' => 'nosniff', 'Cache-Control' => 'private, no-store']); } 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->links = DB::table('portal_resource_links')->where('resource_id', $id) ->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]); $territory = $resource->territory_id ? DB::table('geo_locations')->where('id', $resource->territory_id)->value('name') : null; $resource->territory_name = $territory; $resource->view_url = in_array($resource->media_type, ['application/pdf', 'image/jpeg', 'image/png', 'image/webp'], true) ? route('services.document.view', ['id' => $id]) : null; $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]), ]); } public function viewDocument(int $id): BinaryFileResponse { $document = $this->resources()->where('r.id', $id)->first([ 'd.storage_key', 'd.file_name', 'd.sha256', 'd.media_type', ]); abort_unless($document && in_array($document->media_type, ['application/pdf', 'image/jpeg', 'image/png', 'image/webp'], true), 404); $root = realpath(Storage::disk('local')->path('')); $path = realpath(Storage::disk('local')->path($document->storage_key)); abort_unless($root && $path && str_starts_with(strtolower($path), strtolower($root.DIRECTORY_SEPARATOR)) && is_file($path), 404); $checksum = hash_file('sha256', $path); abort_unless($checksum !== false && hash_equals($document->sha256, $checksum), 404); return response()->file($path, [ 'Content-Type' => $document->media_type, 'X-Content-Type-Options' => 'nosniff', 'Content-Security-Policy' => "sandbox; default-src 'none'; style-src 'unsafe-inline'", 'Cache-Control' => 'private, no-store', ])->setContentDisposition('inline', basename($document->file_name)); } public function downloadForManager(string $id): BinaryFileResponse { $document = DB::table('documents')->where('id', $id)->whereNull('deleted_at')->first([ 'storage_key', 'file_name', 'sha256', 'status', 'malware_scan_status', 'malware_scanned_sha256', ]); abort_unless($document && $document->status === 'active' && $document->malware_scan_status === 'clean' && hash_equals(trim($document->sha256), trim((string) $document->malware_scanned_sha256)), 404); $root = realpath(Storage::disk('local')->path('')); $path = realpath(Storage::disk('local')->path($document->storage_key)); abort_unless($root && $path && str_starts_with(strtolower($path), strtolower($root.DIRECTORY_SEPARATOR)) && is_file($path), 404); $checksum = hash_file('sha256', $path); abort_unless($checksum !== false && hash_equals($document->sha256, $checksum), 404); return response()->download($path, basename($document->file_name), [ 'X-Content-Type-Options' => 'nosniff', 'Cache-Control' => 'private, no-store', ]); } 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'); $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, '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()]); } 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', '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')->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.'); $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'] ?: $document->issuer, 'issued_on' => $data['document_date'] ?: $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); 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.'); $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'] ?: $document->issuer, 'issued_on' => $data['document_date'] ?: $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 { 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' && $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 $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 */ 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'], 'topic' => ['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'.($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'], ]); 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 $rules * @return array */ private function validateManagement(Request $request, array $rules): array { try { return $request->validate($rules); } catch (ValidationException $exception) { throw $this->managementValidationException($exception->errors()); } } /** @param array> $errors */ private function managementValidationException(array $errors): ValidationException { $exception = ValidationException::withMessages($errors); $exception->redirectTo = route('services.manage'); return $exception; } /** @param array $data * @return array */ 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> $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 $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 $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 */ 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 */ 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]; } }