898 lines
61 KiB
PHP
898 lines
61 KiB
PHP
<?php
|
|
|
|
namespace App\Http\Controllers;
|
|
|
|
use App\Jobs\ScanDocumentForMalware;
|
|
use App\Models\InterestRequest;
|
|
use App\Models\Opportunity;
|
|
use App\Models\Sector;
|
|
use App\Services\CountryCatalogue;
|
|
use App\Services\DocumentManagementAccess;
|
|
use App\Services\EconomicReferenceCatalog;
|
|
use App\Services\EditorialReading;
|
|
use App\Services\IdempotencyConflict;
|
|
use App\Services\InterestSubmission;
|
|
use App\Services\InvestmentGeography;
|
|
use App\Services\LegacyContent;
|
|
use App\Services\Malware\MalwareScanner;
|
|
use App\Services\PortalProfileAccess;
|
|
use App\Services\PublicDocumentPresentation;
|
|
use App\Services\SectorOrganization;
|
|
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\Config;
|
|
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;
|
|
use Symfony\Component\HttpKernel\Exception\HttpException;
|
|
|
|
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'] = 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(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(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(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' => $this->articleUrl($id),
|
|
]);
|
|
}
|
|
|
|
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(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(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()]);
|
|
}
|
|
|
|
/**
|
|
* @param array<array-key, mixed> $filters
|
|
*/
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* @return array<array-key, mixed>
|
|
*/
|
|
private function contentAssociations(Request $request): array
|
|
{
|
|
$access = app(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' => Sector::choices()->orderBy('name')->get(['id as value', 'name as label']),
|
|
'territory' => app(InvestmentGeography::class)->publicReferences()->orderBy('name')->get(['id as value', 'name as label']),
|
|
'opportunity' => app(PortalProfileAccess::class)->scope(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::array('virtual-desk.steps', []))->map(fn ($step, $key) => ['value' => $key, 'label' => $step['title']])->values(),
|
|
'activity' => app(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(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(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(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(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(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 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];
|
|
}
|
|
|
|
private function articleUrl(int $id): ?string
|
|
{
|
|
if (! Schema::hasColumn('portal_resources', 'editorial_content')) {
|
|
return null;
|
|
}
|
|
$resource = DB::table('portal_resources')->where('id', $id)->first();
|
|
if (! $resource) {
|
|
return null;
|
|
}
|
|
if ($resource->editorial_content !== null) {
|
|
return '/artigos/'.$id;
|
|
}
|
|
try {
|
|
return app(EditorialReading::class)->original($resource) ? '/artigos/'.$resource->id : null;
|
|
} catch (HttpException) {
|
|
return null;
|
|
}
|
|
}
|
|
}
|