Files
Investidor-V2/app/Http/Controllers/VirtualDeskController.php
T

464 lines
39 KiB
PHP

<?php
namespace App\Http\Controllers;
use App\Jobs\ScanDocumentForMalware;
use App\Models\InvestmentSubmission;
use App\Models\User;
use App\Services\Ai\InvestorGuidance;
use App\Services\CountryCatalogue;
use App\Services\DeskAttachments;
use App\Services\DeskPaymentEvidence;
use App\Services\DeskServiceAvailability;
use App\Services\InstitutionalLinkMonitor;
use App\Services\InvestmentGeography;
use App\Services\InvestorContextConsumers;
use App\Services\ScopedPortalAdministration;
use App\Services\VerifiedDocumentBinary;
use App\Services\VirtualDesk;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Arr;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB;
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\HttpFoundation\StreamedResponse;
use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface;
class VirtualDeskController extends Controller
{
public function catalogue(Request $request, VirtualDesk $desk): JsonResponse|Response
{
$filters = $request->validate(['q' => 'nullable|string|max:120', 'theme' => 'nullable|string|max:80', 'profile' => 'nullable|string|max:100']);
$installed = $desk->installed();
$services = $installed ? app(DeskServiceAvailability::class)->current()->get()->map(fn ($s) => ['id' => $s->id, 'version' => $s->version, 'effective_from' => $s->effective_from ?? null, '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')->whereNull('c.closed_at')->whereNotIn('r.status', ['draft', 'cancelled', 'closed'])->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' => 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);
$hidden = $owner ? ['_creation_hash'] : ['_creation_hash', 'profile', 'company', 'investment', 'name', 'email'];
if ((int) $case->user_id !== $request->user()->id) {
$hidden[] = 'investor_path';
}
$data = ['case' => [...(array) $case, 'payload' => Arr::except($desk->decode($case->payload), $hidden), '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' => ! $case->closed_at && $desk->owns($request->user(), $case), 'manager' => $request->user()->can('manage-portal'),
'canChangeState' => (int) $case->user_id === $request->user()->id && $desk->owns($request->user(), $case), 'activity' => $owner ? $desk->caseActivity($case) : null,
'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' => app(DeskServiceAvailability::class)->current()->get()->map(fn ($s) => ['id' => $s->id, 'title' => json_decode($s->definition, true)['title'], 'version' => $s->version, 'effective_from' => $s->effective_from ?? null]),
'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<int,\stdClass> */
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', 'd.media_type', 'd.size_bytes', 'd.version_no']);
}
public function availableDocuments(Request $request, string $id, VirtualDesk $desk): JsonResponse
{
$case = $desk->case($request->user(), $id, true);
abort_if($case->closed_at, 409, 'Dossier encerrado.');
$data = $request->validate(['q' => 'nullable|string|max:120', 'offset' => 'nullable|integer|min:0']);
$offset = (int) ($data['offset'] ?? 0);
$actor = $request->user();
// Match the existing reuse authority: this dossier, own personal dossiers, or own uploads.
// A document's uploader does not inherit another dossier's reference or private context.
$sources = DB::table('desk_documents as l')->join('desk_cases as c', 'c.id', '=', 'l.case_id')
->where(fn ($q) => $q->where('c.id', $id)->orWhere(fn ($p) => $p->where('c.user_id', $actor->id)->whereNull('c.organization_id')))
->select(['l.id', 'l.document_id', 'l.category', 'l.stage', 'l.source_reference', 'l.valid_until', 'c.title as origin_label']);
$query = DB::table('documents as d')->leftJoinSub($sources, 's', fn ($q) => $q->on('s.document_id', '=', 'd.id'))
->whereNull('d.deleted_at')->where(fn ($q) => $q->where('d.uploaded_by', $actor->id)->orWhereNotNull('s.id'));
$search = trim($data['q'] ?? '');
if ($search !== '') {
$like = '%'.addcslashes($search, '\\%_').'%';
$query->where(fn ($q) => $q->where('d.file_name', 'ilike', $like)->orWhere('s.source_reference', 'ilike', $like)->orWhere('s.origin_label', 'ilike', $like));
}
$rows = $query->orderBy('d.file_name')->orderBy('d.id')->orderBy('s.id')->offset($offset)->limit(26)
->get(['d.id as document_id', 'd.file_name', 'd.version_no', 'd.status', 'd.malware_scan_status', 'd.media_type', 'd.size_bytes', 'd.source_system', 's.id', 's.category', 's.stage', 's.source_reference', 's.valid_until', 's.origin_label']);
$items = $rows->take(25)->filter(function ($document) {
try {
app(VerifiedDocumentBinary::class)->authorizeOpportunityAttachment((object) [...(array) $document, 'id' => $document->document_id]);
return true;
} catch (HttpExceptionInterface $exception) {
if ($exception->getStatusCode() !== 403) {
throw $exception;
}
return false;
}
})->map(fn ($document) => Arr::except((array) $document, ['source_system']))->values();
$this->auditRead($request, 'desk_cases', $id);
return response()->json(['documents' => $items, 'next_offset' => $rows->count() > 25 ? $offset + 25 : null], 200, ['Cache-Control' => 'private, no-store']);
}
/** @return Collection<int,array{id:mixed,request_id:mixed,action:mixed,version:mixed,created_at:mixed,name:mixed,payload:array<string,mixed>}> */
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 caseState(Request $request, string $id, VirtualDesk $desk): JsonResponse|RedirectResponse
{
$result = $desk->changeCaseState($request->user(), $id, $request->all());
return $request->expectsJson() ? response()->json($result) : back()->with('success', $result['replayed'] ? 'Operação já registada; o estado actual do dossier é apresentado.' : ($result['closed_at'] ? 'Dossier encerrado pelo dono. Os registos continuam disponíveis para consulta.' : 'Dossier reaberto pelo dono. Mantêm-se os estados próprios dos pedidos e das tarefas.'));
}
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', 'opportunity' => 'nullable|string|max:100']);
$row = $desk->createRequest($request->user(), $id, $data['service_version_id'], $data['client_key'], $data['opportunity'] ?? null);
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);
$definition = $desk->definition($row);
$territoryPaths = [];
foreach ($definition['fields'] as $key => $field) {
if ($field['type'] === 'territory') {
$territoryPaths[$key] = app(InvestmentGeography::class)->path($payload[$key] ?? null);
}
}
$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);
$caseDocuments = $this->documents($case->id, $request->user());
$paymentEvidence = app(DeskPaymentEvidence::class)->forRequest($id);
$data = ['boundContextRevision' => app(InvestorContextConsumers::class)->requestRevision($row), 'countries' => app(CountryCatalogue::class)->all(), 'toolPrefill' => $desk->requestToolPrefill($request->user(), $row), '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' => $definition, 'territoryPaths' => $territoryPaths,
'serviceSource' => json_decode(DB::table('desk_service_versions')->where('id', $row->service_version_id)->value('source'), true),
'serviceEffectiveFrom' => DB::table('desk_service_versions')->where('id', $row->service_version_id)->selectRaw(app(DeskServiceAvailability::class)->dateProjection())->first()->effective_from,
'owner' => $desk->owns($request->user(), $case), 'manager' => app(ScopedPortalAdministration::class)->deskDecision($request->user(), $row), 'analyst' => (int) $row->analyst_id === $request->user()->id,
'operationsEditable' => ! $case->closed_at && ! in_array($row->status, ['closed', 'cancelled'], true), 'fieldsEditable' => ! $case->closed_at && in_array($row->status, ['draft', 'returned'], true), 'states' => config('virtual-desk.request_states'), 'transitions' => $case->closed_at ? [] : config('virtual-desk.transitions.'.$row->status, []), 'returnReasons' => config('product-metrics.return_reasons'),
'findings' => $desk->findings($payload, $definition), 'ai' => config('virtual-desk.ai'), 'documents' => $caseDocuments->filter(fn ($d) => ! $d->request_id || $d->request_id === $id)->values(),
'reusableDocuments' => $desk->owns($request->user(), $case) && ! $case->closed_at ? $caseDocuments->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()->map(fn ($payment) => [...(array) $payment, 'document_evidence' => $paymentEvidence[$payment->id] ?? null]),
'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 assignments(Request $request): JsonResponse|Response
{
abort_unless(app(ScopedPortalAdministration::class)->administrative($request->user()), 403);
// Triage projection deliberately excludes payloads, documents, investor identity and case titles.
$data = ['requests' => DB::table('desk_requests')->whereNotIn('status', ['draft', 'cancelled', 'closed'])
->whereExists(fn ($q) => $q->selectRaw('1')->from('desk_cases')->whereColumn('desk_cases.id', 'desk_requests.case_id')->whereNull('desk_cases.closed_at'))
->orderBy('created_at')->get(['id', 'protocol', 'status', 'version', 'analyst_id', 'due_at']),
'analysts' => User::whereNotNull('email_verified_at')->orderBy('name')->get(['id', 'name'])];
return $request->expectsJson() ? response()->json($data) : Inertia::render('management/desk-assignments', $data);
}
public function assign(Request $request, string $id, VirtualDesk $desk): JsonResponse|RedirectResponse
{
abort_unless(app(ScopedPortalAdministration::class)->administrative($request->user()), 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'], ['reason.required' => 'Explique o motivo da atribuição.', 'reason.min' => 'Explique o motivo da atribuição com pelo menos 3 caracteres.', 'analyst_id.required' => 'Seleccione o responsável.', 'analyst_id.exists' => 'Seleccione um responsável com conta disponível.']);
DB::transaction(function () use ($request, $id, $desk, $data) {
$row = DB::table('desk_requests')->where('id', $id)->lockForUpdate()->first();
abort_unless($row !== null, 404);
$case = DB::table('desk_cases')->where('id', $row->case_id)->lockForUpdate()->first();
abort_unless($case !== null, 404);
$desk->assertOpen($case);
if ((int) $row->version !== (int) $data['version']) {
throw ValidationException::withMessages(['version' => 'Este pedido foi actualizado. Recupere a lista e tente novamente.']);
}
if (in_array($row->status, ['draft', 'cancelled', 'closed'], true)) {
throw ValidationException::withMessages(['status' => 'Este pedido já não permite uma nova atribuição. Recupere a lista actual.']);
}
$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, false, true);
$desk->assertOpen($case);
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|\Illuminate\Http\Response
{
$row = $desk->request($request->user(), $id);
abort_unless($row->protocol && $row->submitted_snapshot, 404);
$snapshot = $desk->decode($row->submitted_snapshot);
$format = $request->validate(['format' => 'nullable|in:html'])['format'] ?? null;
$this->auditRead($request, 'desk_requests', $id, 'receipt_downloaded');
if ($format === 'html') {
$definition = $desk->definition($row);
return response()->view('desk.receipt', ['record' => $row, 'snapshot' => $snapshot, 'definition' => $definition,
'applicableFields' => $desk->applicableFields($snapshot['payload'] ?? [], $definition)], 200, ['Cache-Control' => 'private, no-store', 'X-Content-Type-Options' => 'nosniff']);
}
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|required_with:reuse_link_id|prohibits:file|uuid', 'reuse_link_id' => 'nullable|uuid|prohibits:file', 'category' => 'nullable|required_without:field_key|string|max:80', 'field_key' => 'nullable|string|max:64',
'stage' => 'nullable|string|max:3', 'request_id' => 'nullable|required_with:field_key|uuid', 'valid_until' => 'nullable|date_format:Y-m-d', 'source_reference' => 'nullable|required_without:document_id|string|max:3000', 'consent' => 'accepted']);
$key = null;
$newId = null;
$linkId = null;
$reusedLink = false;
try {
DB::transaction(function () use ($request, $id, $desk, $data, &$key, &$newId, &$linkId, &$reusedLink) {
$row = ($data['request_id'] ?? null) ? $desk->request($request->user(), $data['request_id'], true, true) : null;
$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 ($row) {
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.');
}
$definition = $row ? $desk->definition($row) : [];
$field = ($data['field_key'] ?? null) ? app(DeskAttachments::class)->field($definition, $data['field_key']) : null;
if ($field) {
if (isset($data['stage']) && $data['stage'] !== $definition['stage']) {
throw ValidationException::withMessages(['stage' => 'A utilização nesta pergunta pertence à etapa do pedido; a origem do documento é conservada separadamente.']);
}
abort_unless(app(DeskAttachments::class)->scopedLinksInstalled(), 409, 'Instale a actualização dos vínculos documentais para este formulário.');
if ($row->status === 'returned' && ! in_array($data['field_key'], json_decode($row->editable_fields ?? '[]', true), true)) {
throw ValidationException::withMessages(['field_key' => 'Esta pergunta não está autorizada para correcção.']);
}
}
$document = null;
if ($request->hasFile('file')) {
$file = $request->file('file');
if ($field) {
app(DeskAttachments::class)->checkMetadata((object) ['media_type' => $file->getMimeType(), 'size_bytes' => $file->getSize()], $field, 'file');
}
$hash = hash_file('sha256', $file->getRealPath());
$document = DB::table('desk_documents as l')->join('documents as d', 'd.id', '=', 'l.document_id')->where('l.case_id', $id)->whereNull('d.deleted_at')->where('d.sha256', $hash)->first(['d.*']);
if (! $document) {
abort_if(DB::table('desk_documents')->where('case_id', $id)->distinct()->count('document_id') >= 100, 422, 'Limite de ficheiros distintos do dossier atingido.');
$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']);
$document = DB::table('documents')->where('id', $newId)->firstOrFail();
}
} else {
$document = DB::table('documents')->where('id', $data['document_id'])->whereNull('deleted_at')->first();
abort_unless($document !== null, 404);
$owned = (int) $document->uploaded_by === $request->user()->id || DB::table('desk_documents')->where('case_id', $id)->where('document_id', $document->id)->exists()
|| DB::table('desk_documents as l')->join('desk_cases as c', 'c.id', '=', 'l.case_id')->where('l.document_id', $document->id)->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 ($field) {
app(DeskAttachments::class)->checkMetadata($document, $field, 'document_id', true);
}
app(VerifiedDocumentBinary::class)->authorizeOpportunityAttachment($document);
$existing = DB::table('desk_documents')->where('case_id', $id)->where('document_id', $document->id)
->where(fn ($q) => ($data['request_id'] ?? null) ? $q->whereNull('request_id')->orWhere('request_id', $data['request_id']) : $q->whereNull('request_id'))->orderByRaw('request_id IS NULL DESC')->first();
if ($existing) {
$linkId = $existing->id;
$reusedLink = true;
return;
}
if (! app(DeskAttachments::class)->scopedLinksInstalled() && DB::table('desk_documents')->where('case_id', $id)->where('document_id', $document->id)->exists()) {
throw ValidationException::withMessages(['document_id' => 'Instale a actualização dos vínculos para reutilizar o ficheiro noutro pedido, preservando o âmbito anterior.']);
}
$inCase = DB::table('desk_documents')->where('case_id', $id)->where('document_id', $document->id)->exists();
abort_if(! $inCase && DB::table('desk_documents')->where('case_id', $id)->distinct()->count('document_id') >= 100, 422, 'Limite de ficheiros distintos do dossier atingido.');
$sourceLink = DB::table('desk_documents as l')->join('desk_cases as c', 'c.id', '=', 'l.case_id')->where('l.document_id', $document->id)
->where(fn ($q) => $q->where('l.case_id', $id)->orWhere(fn ($p) => $p->where('c.user_id', $request->user()->id)->whereNull('c.organization_id')))
->when($data['reuse_link_id'] ?? null, fn ($q, $sourceId) => $q->where('l.id', $sourceId))->orderBy('l.created_at')->orderBy('l.id')->first(['l.*']);
abort_if(($data['reuse_link_id'] ?? null) && ! $sourceLink, 403, 'Não há autorização para reutilizar esta referência documental.');
$source = $data['source_reference'] ?? $sourceLink?->source_reference;
if (! is_string($source) || trim($source) === '') {
throw ValidationException::withMessages(['source_reference' => 'Indique a proveniência deste documento; não existe uma referência autorizada para reutilizar.']);
}
$linkId = (string) Str::uuid();
DB::table('desk_documents')->insert(['id' => $linkId, 'case_id' => $id, 'document_id' => $document->id, 'request_id' => $data['request_id'] ?? null,
'category' => $field ? Str::limit($field['label'], 80, '') : $data['category'], 'stage' => $data['stage'] ?? ($field ? $definition['stage'] : $sourceLink?->stage), 'valid_until' => array_key_exists('valid_until', $data) ? $data['valid_until'] : $sourceLink?->valid_until, 'source_reference' => $source, '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' => $document->id, 'link_id' => $linkId, 'source' => $source, 'consent' => true, 'field_key' => $data['field_key'] ?? null, 'source_link_id' => $sourceLink?->id,
'reused_source' => $sourceLink ? ['source_reference' => $sourceLink->source_reference, 'category' => $sourceLink->category, 'stage' => $sourceLink->stage, 'valid_until' => $sourceLink->valid_until, 'document_id' => $document->id, 'version_no' => $document->version_no, 'sha256' => trim($document->sha256)] : null], $row);
});
} catch (\Throwable $e) {
if ($key) {
Storage::disk('local')->delete($key);
} throw $e;
}
if ($newId) {
ScanDocumentForMalware::dispatch($newId)->afterCommit();
}
$available = $this->documents($id, $request->user());
return $request->expectsJson() ? response()->json(['ok' => true, 'link_id' => $linkId, 'reused_link' => $reusedLink, 'case_version' => (int) DB::table('desk_cases')->where('id', $id)->value('version'),
'documents' => $available->filter(fn ($d) => ! ($data['request_id'] ?? null) || ! $d->request_id || $d->request_id === $data['request_id'])->values(), 'reusable_documents' => $desk->owns($request->user(), $desk->case($request->user(), $id)) ? $available->values() : []]) : back()->with('success', $reusedLink ? 'Este documento já estava associado; conservou-se a referência registada.' : 'Documento privado associado; consulte o estado da análise.');
}
public function download(Request $request, string $id, string $document, VirtualDesk $desk): BinaryFileResponse|\Illuminate\Http\Response
{
$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);
$response = app(VerifiedDocumentBinary::class)->respond($doc);
$this->auditRead($request, 'documents', $doc->id, 'document_downloaded');
return $response;
}
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]);
}
}