validate(['q' => 'nullable|string|max:120', 'theme' => 'nullable|string|max:80', 'profile' => 'nullable|string|max:100']); $installed = $desk->installed(); $services = $installed ? DB::table('desk_service_versions')->where('status', 'published')->orderBy('code')->orderByDesc('version')->get()->unique('code')->map(fn ($s) => ['id' => $s->id, 'version' => $s->version, 'definition' => json_decode($s->definition, true), 'source' => json_decode($s->source, true)])->values() : collect(); $services = $services->filter(fn ($s) => (! ($filters['q'] ?? '') || str_contains(mb_strtolower(json_encode($s['definition'], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR)), mb_strtolower($filters['q']))) && (! ($filters['theme'] ?? '') || $s['definition']['theme'] === $filters['theme']) && (! ($filters['profile'] ?? '') || $s['definition']['profile'] === $filters['profile']))->values(); $page = max(1, (int) $request->query('page', 1)); $data = ['installed' => $installed, 'services' => $services->slice(($page - 1) * 12, 12)->values(), 'total' => $services->count(), 'page' => $page, 'filters' => $filters, 'prepared' => $installed ? [] : config('virtual-desk.services'), 'source' => config('virtual-desk.source'), 'authenticated' => (bool) $request->user(), 'support' => config('portal.support')]; return $request->expectsJson() ? response()->json($data) : Inertia::render('desk/catalogue', $data); } public function panel(Request $request, VirtualDesk $desk): JsonResponse|Response { $actor = $request->user(); $organizations = DB::table('memberships')->where('user_id', $actor->id)->whereNull('deleted_at')->pluck('organization_id'); $cases = DB::table('desk_cases')->where(fn ($q) => $q->where(fn ($p) => $p->where('user_id', $actor->id)->whereNull('organization_id'))->orWhereIn('organization_id', $organizations))->orderByDesc('updated_at')->get(['id', 'title', 'version', 'updated_at']); $queue = DB::table('desk_requests as r')->join('desk_cases as c', 'c.id', '=', 'r.case_id')->whereNotIn('r.status', ['draft', 'cancelled'])->when(! $actor->can('manage-portal'), fn ($q) => $q->where('r.analyst_id', $actor->id)) ->orderByRaw('r.due_at ASC NULLS LAST')->get(['r.id', 'r.protocol', 'r.status', 'r.version', 'r.analyst_id', 'r.due_at', 'c.title']); $data = ['cases' => $cases, 'queue' => $queue, 'notifications' => DB::table('desk_notifications')->where('user_id', $actor->id)->orderByDesc('id')->limit(100)->get(), 'legacyRequests' => DB::table('interest_requests')->where('metadata->portal_user_id', $actor->id)->whereNull('deleted_at')->orderByDesc('created_at')->get(['id', 'reference', 'status', 'created_at']), 'companies' => DB::table('investment_company_declarations')->where('user_id', $actor->id)->get(['id', 'payload'])->map(fn ($c) => ['id' => $c->id, 'name' => $desk->decode($c->payload)['name'] ?? 'Declaração empresarial']), 'submissions' => \App\Models\InvestmentSubmission::where('user_id', $actor->id)->get()->map(fn ($s) => ['id' => $s->id, 'status' => $s->status, 'name' => ($s->opportunity_id ? DB::table('opportunities')->where('id', $s->opportunity_id)->value('title') : null) ?? ($s->dossier_id ? DB::table('investment_dossiers')->where('id', $s->dossier_id)->value('title') : null) ?? ($s->payload['organisation'] ?? null)]), 'journeys' => DB::table('investor_journeys as j')->leftJoin('investment_dossiers as d', 'd.id', '=', 'j.dossier_id')->where('j.user_id', $actor->id)->whereNull('j.deleted_at')->get(['j.id', 'j.dossier_id', 'j.status', 'd.title as name']), 'simulationDrafts' => DB::table('simulation_form_drafts')->where('owner_user_id', $actor->id)->whereNull('deleted_at')->where(fn ($q) => $q->whereNull('expires_at')->orWhere('expires_at', '>', now()))->get(['id', 'version_no']), 'guidanceTasks' => app(InvestorGuidance::class)->installed() ? DB::table('tasks as t')->join('desk_cases as c', 'c.id', '=', 't.desk_case_id') ->whereNull('t.deleted_at')->when(! $actor->can('manage-portal'), fn ($q) => $q->where(fn ($p) => $p->where('t.assignee_user_id', $actor->id)->orWhere(fn ($s) => $s->where('c.user_id', $actor->id)->whereNull('c.organization_id'))->orWhereIn('c.organization_id', $organizations))) ->orderByRaw('t.due_at ASC NULLS LAST')->get(['t.id', 't.title', 't.status', 't.due_at', 'c.id as case_id', 'c.title as case_title']) : [], 'organizations' => DB::table('organizations')->whereIn('id', $organizations)->get(['id', 'name']), 'manager' => $actor->can('manage-portal'), 'states' => config('virtual-desk.request_states')]; return $request->expectsJson() ? response()->json($data) : Inertia::render('desk/panel', $data); } public function createCase(Request $request, VirtualDesk $desk): JsonResponse|RedirectResponse { $case = $desk->createCase($request->user(), $request->all()); return $request->expectsJson() ? response()->json(['id' => $case->id, 'version' => $case->version], 201) : redirect('/balcao-virtual/dossiers/'.$case->id) ->with('success', 'Dossier guardado e disponível para continuar. Próxima ação: escolha um serviço publicado e prepare o rascunho de apoio. Nenhum pedido foi submetido.'); } public function showCase(Request $request, string $id, VirtualDesk $desk): JsonResponse|Response { $case = $desk->case($request->user(), $id); $owner = $desk->owns($request->user(), $case, false); $this->auditRead($request, 'desk_cases', $id); $data = ['case' => [...(array) $case, 'payload' => Arr::except($desk->decode($case->payload), $owner ? ['_creation_hash'] : ['_creation_hash', 'profile', 'company', 'investment', 'name', 'email']), 'workflow' => json_decode($case->workflow, true)], 'legacyRequest' => ($case->interest_request_id ?? null) ? DB::table('interest_requests as r')->leftJoin('users as u', 'u.id', '=', 'r.assigned_to')->where('r.id', $case->interest_request_id)->whereNull('r.deleted_at') ->when(! $request->user()->can('manage-portal'), fn ($q) => $q->where('r.metadata->portal_user_id', $request->user()->id)) ->first(['r.id', 'r.reference', 'r.status', 'r.next_action', 'r.next_action_due_at', 'u.name as responsible_name']) : null, 'owner' => $owner, 'canWrite' => $desk->owns($request->user(), $case), 'manager' => $request->user()->can('manage-portal'), 'requests' => DB::table('desk_requests')->where('case_id', $id)->when(! $owner && ! $request->user()->can('manage-portal'), fn ($q) => $q->where('analyst_id', $request->user()->id))->orderByDesc('updated_at')->get(['id', 'status', 'protocol', 'service_version_id', 'version']), 'services' => DB::table('desk_service_versions')->where('status', 'published')->orderByDesc('version')->get(['id', 'code', 'version', 'definition'])->unique('code')->values()->map(fn ($s) => ['id' => $s->id, 'title' => json_decode($s->definition, true)['title'], 'version' => $s->version]), 'documents' => $this->documents($id, $request->user()), 'events' => $this->events($id, $desk, $request->user()), 'stepStates' => config('virtual-desk.step_states'), 'states' => config('virtual-desk.request_states'), 'links' => DB::table('desk_institutional_links')->get()->map(fn ($link) => [...(array) $link, 'monitor' => app(InstitutionalLinkMonitor::class)->status($link)]), 'source' => config('virtual-desk.source'), 'support' => config('portal.support'), 'guidance' => app(InvestorGuidance::class)->installed() ? app(InvestorGuidance::class)->view($request->user(), $id) : null]; return $request->expectsJson() ? response()->json($data) : Inertia::render('desk/case', $data); } /** @return Collection */ private function documents(string $case, User $actor): Collection { return DB::table('desk_documents as l')->join('documents as d', 'd.id', '=', 'l.document_id')->where('l.case_id', $case)->whereNull('d.deleted_at') ->when(! app(VirtualDesk::class)->owns($actor, app(VirtualDesk::class)->case($actor, $case), false) && ! $actor->can('manage-portal'), fn ($q) => $q->where(fn ($p) => $p->whereNull('l.request_id')->orWhereIn('l.request_id', DB::table('desk_requests')->where('analyst_id', $actor->id)->select('id')))) ->get(['l.id', 'l.document_id', 'l.request_id', 'l.category', 'l.stage', 'l.source_reference', 'l.valid_until', 'd.file_name', 'd.status', 'd.malware_scan_status', 'd.sha256']); } /** @return Collection}> */ private function events(string $case, VirtualDesk $desk, User $actor): Collection { return DB::table('desk_events as e')->join('users as u', 'u.id', '=', 'e.actor_id')->where('e.case_id', $case) ->when(! $desk->owns($actor, $desk->case($actor, $case), false) && ! $actor->can('manage-portal'), fn ($q) => $q->where(fn ($p) => $p->whereNull('e.request_id')->orWhereIn('e.request_id', DB::table('desk_requests')->where('analyst_id', $actor->id)->select('id')))) ->orderBy('e.event_order')->get(['e.id', 'e.request_id', 'e.action', 'e.payload', 'e.version', 'e.created_at', 'u.name'])->map(fn ($e) => ['id' => $e->id, 'request_id' => $e->request_id, 'action' => $e->action, 'version' => $e->version, 'created_at' => $e->created_at, 'name' => $e->name, 'payload' => $desk->decode($e->payload)]); } public function stage(Request $request, string $id, VirtualDesk $desk): JsonResponse|RedirectResponse { $case = $desk->stage($request->user(), $id, $request->all()); return $request->expectsJson() ? response()->json(['version' => $case->version]) : back()->with('success', 'Etapa e evidência registadas.'); } public function createRequest(Request $request, string $id, VirtualDesk $desk): JsonResponse|RedirectResponse { $data = $request->validate(['service_version_id' => 'required|uuid', 'client_key' => 'required|uuid']); $row = $desk->createRequest($request->user(), $id, $data['service_version_id'], $data['client_key']); return $request->expectsJson() ? response()->json(['id' => $row->id, 'version' => $row->version], 201) : redirect('/balcao-virtual/pedidos/'.$row->id); } public function showRequest(Request $request, string $id, VirtualDesk $desk): JsonResponse|Response { $row = $desk->request($request->user(), $id); $case = $desk->case($request->user(), $row->case_id); $payload = $desk->decode($row->payload); $messageKinds = []; foreach (DB::table('desk_events')->where('request_id', $id)->where('action', 'message_added')->get() as $event) { $original = $desk->decode($event->payload); if (isset($original['message_id'], $original['kind'])) { $messageKinds[$original['message_id']] = $original['kind']; } } $this->auditRead($request, 'desk_requests', $id); $data = ['countries' => app(\App\Services\CountryCatalogue::class)->all(), 'request' => [...(array) $row, 'payload' => $payload, 'submitted_snapshot' => $desk->decode($row->submitted_snapshot), 'editable_fields' => json_decode($row->editable_fields ?? '[]', true)], 'case' => ['id' => $case->id, 'title' => $case->title], 'caseVersion' => $case->version, 'definition' => $desk->definition($row), 'serviceSource' => json_decode(DB::table('desk_service_versions')->where('id', $row->service_version_id)->value('source'), true), 'owner' => $desk->owns($request->user(), $case), 'manager' => $request->user()->can('manage-portal'), 'analyst' => (int) $row->analyst_id === $request->user()->id, 'fieldsEditable' => in_array($row->status, ['draft', 'returned'], true), 'states' => config('virtual-desk.request_states'), 'transitions' => config('virtual-desk.transitions.'.$row->status, []), 'findings' => $desk->findings($payload), 'ai' => config('virtual-desk.ai'), 'documents' => $this->documents($case->id, $request->user())->filter(fn ($d) => ! $d->request_id || $d->request_id === $id)->values(), 'messages' => DB::table('desk_messages as m')->join('users as u', 'u.id', '=', 'm.actor_id')->where('request_id', $id)->orderBy('m.created_at')->get(['m.id', 'm.actor_id', 'm.body', 'm.created_at', 'u.name'])->map(fn ($m) => [...(array) $m, 'body' => Crypt::decryptString($m->body), 'kind' => $messageKinds[$m->id] ?? 'historical_unclassified']), 'events' => $this->events($case->id, $desk, $request->user())->filter(fn ($e) => $e['request_id'] === $id)->values(), 'revisions' => DB::table('desk_revisions')->where('request_id', $id)->orderByDesc('version')->get(['version', 'snapshot', 'created_at'])->map(fn ($r) => [...(array) $r, 'snapshot' => $desk->decode($r->snapshot)]), 'payments' => DB::table('desk_payments')->where('request_id', $id)->get(), 'analysts' => $request->user()->can('manage-portal') ? User::orderBy('name')->get(['id', 'name']) : [], 'territories' => app(InvestmentGeography::class)->publicReferences()->orderBy('name')->get(['id', 'name', 'location_type'])]; return $request->expectsJson() ? response()->json($data) : Inertia::render('desk/request', $data); } public function save(Request $request, string $id, VirtualDesk $desk): JsonResponse|RedirectResponse { $row = $desk->save($request->user(), $id, $request->all(), $request->boolean('submit')); return $request->expectsJson() ? response()->json(['id' => $row->id, 'version' => $row->version, 'status' => $row->status, 'protocol' => $row->protocol]) : back()->with('success', $request->boolean('submit') ? 'Pedido submetido; protocolo disponível.' : 'Rascunho guardado.'); } public function transition(Request $request, string $id, VirtualDesk $desk): JsonResponse|RedirectResponse { $row = $desk->transition($request->user(), $id, $request->all()); return $request->expectsJson() ? response()->json(['version' => $row->version, 'status' => $row->status]) : back()->with('success', 'Decisão interna registada.'); } public function assign(Request $request, string $id, VirtualDesk $desk): JsonResponse|RedirectResponse { abort_unless($request->user()->can('manage-portal'), 403); $data = $request->validate(['version' => 'required|integer', 'analyst_id' => 'required|integer|exists:users,id', 'reason' => 'required|string|min:3|max:3000', 'due_at' => 'nullable|date']); DB::transaction(function () use ($request, $id, $desk, $data) { $row = $desk->request($request->user(), $id, false, true); $case = $desk->case($request->user(), $row->case_id); abort_unless((int) $row->version === (int) $data['version'] && ! in_array($row->status, ['draft', 'cancelled', 'closed'], true), 409); $analyst = User::where('id', $data['analyst_id'])->firstOrFail(); abort_unless($analyst->hasVerifiedEmail(), 422, 'O responsável deve ter conta verificada.'); abort_if($desk->owns($analyst, $case, false), 422, 'O proponente não pode ser o analista.'); DB::table('desk_requests')->where('id', $id)->update(['analyst_id' => $analyst->id, 'due_at' => $data['due_at'] ?? null, 'version' => $row->version + 1, 'updated_at' => now()]); $desk->event($case, $request->user(), 'analyst_assigned', [...$data, 'deadline_basis' => 'internal_target_not_legal_deadline'], DB::table('desk_requests')->where('id', $id)->first()); }); return $request->expectsJson() ? response()->json(['ok' => true]) : back()->with('success', 'Analista e meta interna atribuídos.'); } public function message(Request $request, string $id, VirtualDesk $desk): JsonResponse|RedirectResponse { $data = $request->validate(['body' => 'required|string|min:1|max:10000', 'client_key' => 'required|uuid']); DB::transaction(function () use ($request, $id, $desk, $data) { $row = $desk->request($request->user(), $id, false, true); $case = $desk->case($request->user(), $row->case_id); abort_if(in_array($row->status, ['closed', 'cancelled'], true), 409, 'Pedido encerrado.'); abort_unless($desk->owns($request->user(), $case) || $request->user()->can('manage-portal') || (int) $row->analyst_id === $request->user()->id, 403); $existing = DB::table('desk_messages')->where('request_id', $id)->where('actor_id', $request->user()->id)->where('client_key', $data['client_key'])->first(); if ($existing) { abort_unless(Crypt::decryptString($existing->body) === $data['body'], 409); return; } $messageId = (string) Str::uuid(); DB::table('desk_messages')->insert(['id' => $messageId, 'request_id' => $id, 'actor_id' => $request->user()->id, 'client_key' => $data['client_key'], 'body' => Crypt::encryptString($data['body'])]); $desk->event($case, $request->user(), 'message_added', ['message_id' => $messageId, 'kind' => $desk->owns($request->user(), $case) ? 'investor_message' : 'responsible_response'], $row); }); return $request->expectsJson() ? response()->json(['ok' => true]) : back()->with('success', 'Mensagem privada guardada.'); } public function receipt(Request $request, string $id, VirtualDesk $desk): StreamedResponse { $row = $desk->request($request->user(), $id); abort_unless($row->protocol && $row->submitted_snapshot, 404); $snapshot = $desk->decode($row->submitted_snapshot); $this->auditRead($request, 'desk_requests', $id, 'receipt_downloaded'); return response()->streamDownload(function () use ($row, $snapshot) { echo 'Balcão Virtual — recibo de submissão interna'.PHP_EOL.$row->protocol.PHP_EOL.'Data: '.$row->submitted_at.PHP_EOL.'Versão: '.$row->version.PHP_EOL; echo json_encode($snapshot, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR); }, $row->protocol.'.txt', ['Content-Type' => 'text/plain; charset=utf-8', 'Cache-Control' => 'private, no-store']); } public function attach(Request $request, string $id, VirtualDesk $desk): JsonResponse|RedirectResponse { $data = $request->validate(['version' => 'required|integer', 'file' => 'nullable|required_without:document_id|file|mimetypes:application/pdf,image/png,image/jpeg,text/plain|max:10240', 'document_id' => 'nullable|required_without:file|uuid', 'category' => 'required|string|max:80', 'stage' => 'nullable|string|max:3', 'request_id' => 'nullable|uuid', 'valid_until' => 'nullable|date_format:Y-m-d', 'source_reference' => 'required|string|max:3000', 'consent' => 'accepted']); $key = null; $newId = null; try { DB::transaction(function () use ($request, $id, $desk, $data, &$key, &$newId) { $case = $desk->case($request->user(), $id, true, true); abort_if($case->closed_at, 409, 'Dossier encerrado.'); abort_unless((int) $case->version === (int) $data['version'], 409); if ($data['stage'] ?? null) { abort_unless(array_key_exists($data['stage'], config('virtual-desk.steps')), 422); } if ($data['request_id'] ?? null) { $row = $desk->request($request->user(), $data['request_id'], true, true); abort_unless($row->case_id === $id && in_array($row->status, ['draft', 'returned'], true), 409, 'Anexos de pedidos submetidos são imutáveis; use a devolução para corrigir.'); } abort_if(DB::table('desk_documents')->where('case_id', $id)->count() >= 100, 422, 'Limite de anexos do dossier atingido.'); if ($request->hasFile('file')) { $file = $request->file('file'); $hash = hash_file('sha256', $file->getRealPath()); $duplicate = DB::table('desk_documents as l')->join('documents as d', 'd.id', '=', 'l.document_id')->where('l.case_id', $id)->where('d.sha256', $hash)->first(); if ($duplicate) { return; } $newId = (string) Str::uuid(); $key = $file->storeAs('virtual-desk/'.$id, $newId, 'local'); abort_unless(is_string($key) && $key !== '', 500); DB::table('documents')->insert(['id' => $newId, 'source_system' => 'virtual-desk', 'file_name' => basename($file->getClientOriginalName()), 'media_type' => $file->getMimeType(), 'storage_key' => $key, 'sha256' => $hash, 'size_bytes' => $file->getSize(), 'uploaded_by' => $request->user()->id, 'status' => 'quarantined', 'data_classification' => 'confidential']); $documentId = $newId; } else { $documentId = $data['document_id']; $document = DB::table('documents')->where('id', $documentId)->whereNull('deleted_at')->first(); abort_unless($document !== null, 404); $owned = (int) $document->uploaded_by === $request->user()->id || DB::table('desk_documents as l')->join('desk_cases as c', 'c.id', '=', 'l.case_id')->where('l.document_id', $documentId)->where('c.user_id', $request->user()->id)->whereNull('c.organization_id')->exists(); abort_unless($owned, 403, 'Não há autorização para reutilizar este documento.'); if (DB::table('desk_documents')->where('case_id', $id)->where('document_id', $documentId)->exists()) { return; } } DB::table('desk_documents')->insert(['id' => (string) Str::uuid(), 'case_id' => $id, 'document_id' => $documentId, 'request_id' => $data['request_id'] ?? null, 'category' => $data['category'], 'stage' => $data['stage'] ?? null, 'valid_until' => $data['valid_until'] ?? null, 'source_reference' => $data['source_reference'], 'linked_by' => $request->user()->id]); DB::table('desk_cases')->where('id', $id)->update(['version' => $case->version + 1, 'updated_at' => now()]); $desk->event(DB::table('desk_cases')->where('id', $id)->first(), $request->user(), 'document_linked', ['document_id' => $documentId, 'source' => $data['source_reference'], 'consent' => true]); }); } catch (\Throwable $e) { if ($key) { Storage::disk('local')->delete($key); } throw $e; } if ($newId) { ScanDocumentForMalware::dispatch($newId)->afterCommit(); } return $request->expectsJson() ? response()->json(['ok' => true]) : back()->with('success', 'Documento privado associado; consulte o estado da análise.'); } public function download(Request $request, string $id, string $document, VirtualDesk $desk): BinaryFileResponse { $desk->case($request->user(), $id); $doc = DB::table('desk_documents as l')->join('documents as d', 'd.id', '=', 'l.document_id')->where('l.case_id', $id)->where('l.id', $document)->whereNull('d.deleted_at')->first(['d.*', 'l.request_id']); if ($doc && $doc->request_id) { $desk->request($request->user(), $doc->request_id); } abort_unless($doc && $doc->status === 'active' && $doc->malware_scan_status === 'clean' && trim($doc->sha256) === $doc->malware_scanned_sha256, 404); $path = realpath(Storage::disk('local')->path($doc->storage_key)); $root = realpath(Storage::disk('local')->path('')); abort_unless($path && $root && str_starts_with($path, $root.DIRECTORY_SEPARATOR) && is_file($path) && is_string($actualHash = hash_file('sha256', $path)) && hash_equals(trim($doc->sha256), $actualHash), 404); $this->auditRead($request, 'documents', $doc->id, 'document_downloaded'); return response()->download($path, $doc->file_name, ['Cache-Control' => 'private, no-store']); } public function notification(Request $request, int $id): RedirectResponse { DB::table('desk_notifications')->where('id', $id)->where('user_id', $request->user()->id)->update(['read_at' => now()]); return back(); } public function redirectLink(Request $request, string $id, VirtualDesk $desk): RedirectResponse { $case = $desk->case($request->user(), $request->validate(['case_id' => 'required|uuid'])['case_id']); $link = DB::table('desk_institutional_links')->where('id', $id)->first(); if (! $link || ! app(InstitutionalLinkMonitor::class)->status($link)['available'] || $request->boolean('unavailable')) { $desk->event($case, $request->user(), 'institutional_link_unavailable', ['link_id' => $id, 'contact' => $link?->contact, 'scope' => 'internal_services_remain_available']); return redirect('/balcao-virtual/dossiers/'.$case->id)->with('warning', 'Ligação indisponível ou por verificar. Contacto alternativo: '.($link?->contact ?: (config('portal.support.email') ?: 'utilize o pedido de apoio neste dossier')).'. Pode continuar a preparação e os serviços internos.'); } abort_unless(filter_var($link->url, FILTER_VALIDATE_URL) && parse_url($link->url, PHP_URL_SCHEME) === 'https', 422); $desk->event($case, $request->user(), 'institutional_referral', ['link_id' => $id, 'version' => $link->version, 'purpose' => $link->purpose]); // No personal data, case ID or protocol appended to the external URL. return redirect()->away($link->url)->withHeaders(['Referrer-Policy' => 'no-referrer']); } private function auditRead(Request $request, string $type, string $id, string $action = 'consulted'): void { DB::table('audit_events')->insert(['source_system' => 'virtual-desk', 'entity_type' => $type, 'entity_id' => $id, 'actor_user_id' => $request->user()->id, 'action' => $action]); } }