$this->resources()->where('r.essential', true) ->orderBy('r.title')->limit(6)->get(['r.id', 'r.title'])]); } 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('d.status', 'active')->where('d.data_classification', 'public')->whereNull('d.deleted_at'); } public function page(Request $request): Response { $page = $request->route()->defaults['page']; $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']]); $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'] as $field) { if (! empty($filters[$field])) { $query->where('r.'.$field, $filters[$field]); } } if (! empty($filters['q'])) { $query->where('r.title', 'ilike', '%'.$filters['q'].'%'); } $props['resources'] = $query->orderByDesc('r.essential')->orderBy('r.title')->select(['r.id', 'r.title', 'r.category', 'r.topic', 'r.source_reference', 'r.essential'])->paginate(20)->withQueryString(); } 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') { $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 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 manage(): Response { return Inertia::render('portal/manage', ['resources' => DB::table('portal_resources')->latest()->limit(100)->get(), 'partnerships' => DB::table('portal_partnerships')->latest()->limit(100)->get(), 'documents' => DB::table('documents')->where('status', 'active')->where('data_classification', 'public')->whereNull('deleted_at')->get(['id', 'file_name'])]); } public function upload(Request $request): RedirectResponse { $request->validate(['file' => ['required', 'file', 'mimetypes:application/pdf,text/plain', 'max:10240'], 'publication_consent' => ['accepted']]); $file = $request->file('file'); $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) { DB::table('documents')->insert(['id' => $id, 'source_system' => 'portal-services', 'file_name' => basename($file->getClientOriginalName()), 'media_type' => $file->getMimeType(), 'storage_key' => $key, 'sha256' => hash_file('sha256', Storage::disk('local')->path($key)), 'status' => 'active', 'data_classification' => 'public']); DB::table('audit_events')->insert(['source_system' => 'portal-services', 'entity_type' => 'documents', 'entity_id' => $id, 'actor_user_id' => $request->user()->id, 'action' => 'public_document_uploaded']); }); } catch (\Throwable $exception) { Storage::disk('local')->delete($key); throw $exception; } return back()->with('success', 'Documento carregado. Seleccione-o no catálogo e registe a fonte para publicar.'); } public function resource(Request $request): RedirectResponse { $data = $request->validate(['document_id' => ['required', 'uuid'], 'title' => ['required', 'string', 'max:200'], 'category' => ['required', 'string', 'max:80'], 'topic' => ['required', 'string', 'max:80'], 'source_reference' => ['required', 'string', 'min:10', 'max:2000'], 'legislation' => ['required', 'boolean'], 'essential' => ['required', 'boolean'], 'published' => ['required', 'boolean']]); DB::transaction(function () use ($data, $request) { abort_unless(DB::table('documents')->where('id', $data['document_id'])->where('status', 'active')->where('data_classification', 'public')->whereNull('deleted_at')->lockForUpdate()->exists(), 422, 'Seleccione um documento público activo.'); $id = DB::table('portal_resources')->insertGetId([...$data, 'approved_by' => $request->user()->id, 'created_at' => now(), 'updated_at' => now()]); $this->audit($request, 'resource_registered', 'portal_resources', $id); }); return back()->with('success', 'Documento catalogado.'); } public function moderate(Request $request, string $type, int $id): RedirectResponse { $data = $request->validate(['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); DB::table($table)->where('id', $id)->update($type === 'resources' ? ['published' => $data['published'], 'approved_by' => $request->user()->id, 'updated_at' => now()] : ['status' => $data['published'] ? 'published' : 'withdrawn', 'reviewed_by' => $request->user()->id, 'updated_at' => now()]); $this->audit($request, $data['published'] ? 'published' : 'withdrawn', $table, $id); }); return back()->with('success', 'Publicação actualizada.'); } private function audit(Request $request, string $action, string $table, int $id): void { DB::table('audit_events')->insert(['source_system' => 'portal-services', 'entity_type' => $table, 'actor_user_id' => $request->user()->id, 'action' => $action, 'after_data' => json_encode(['record_id' => $id], JSON_THROW_ON_ERROR)]); } }