vrsao 2
tests / ci (push) Canceled after 0s

This commit is contained in:
2026-10-08 14:02:01 +02:00
parent bdd766c68a
commit 1106509116
354 changed files with 48275 additions and 24635 deletions
@@ -95,7 +95,7 @@ class InvestmentZoneAttachmentController extends Controller
return DB::transaction(function () use ($request, $id, $action, $data): JsonResponse {
$attachment = DB::table('investment_zone_attachments')->where('id', $id)->lockForUpdate()->first();
abort_unless($attachment, 404);
abort_unless($attachment !== null, 404);
abort_if((int) $attachment->version_no !== (int) $data['version_no'], 409, 'O anexo foi alterado. Recarregue antes de continuar.');
$version = (int) $attachment->version_no + 1;
$changes = ['version_no' => $version, 'updated_at' => now()];
@@ -137,7 +137,7 @@ class InvestmentZoneAttachmentController extends Controller
->where('a.id', $id)->where('a.zone_id', $zoneId)->where('d.status', 'active')->whereNull('d.deleted_at')
->where('d.malware_scan_status', 'clean')->whereColumn('d.malware_scanned_sha256', 'd.sha256')
->first(['d.file_name', 'd.storage_key', 'd.sha256', 'd.media_type', 'd.malware_scanned_sha256']);
abort_unless($record, 404);
abort_unless($record !== null, 404);
return $this->verifiedDownload($zoneId, $record);
}
@@ -150,7 +150,7 @@ class InvestmentZoneAttachmentController extends Controller
->where('a.review_status', 'reviewed')->where('a.rights_status', 'confirmed')->where('a.personal_data_status', 'none')
->where('d.status', 'active')->whereNull('d.deleted_at')->where('d.malware_scan_status', 'clean')
->whereColumn('d.malware_scanned_sha256', 'd.sha256')->first(['d.file_name', 'd.storage_key', 'd.sha256', 'd.media_type', 'd.malware_scanned_sha256']);
abort_unless($record, 404);
abort_unless($record !== null, 404);
$extension = match ($record->media_type) {
'application/pdf' => 'pdf', 'image/jpeg' => 'jpg', 'image/png' => 'png', 'image/webp' => 'webp', default => 'bin'
@@ -159,7 +159,7 @@ class InvestmentZoneAttachmentController extends Controller
return $this->verifiedDownload($zoneId, $record, str_starts_with($record->media_type, 'image/'), 'zone-attachment-'.$id.'.'.$extension);
}
private function verifiedDownload(string $zoneId, object $document, bool $inline = false, ?string $downloadName = null): BinaryFileResponse
private function verifiedDownload(string $zoneId, \stdClass $document, bool $inline = false, ?string $downloadName = null): BinaryFileResponse
{
$root = realpath(Storage::disk('local')->path('investment-zones/'.$zoneId));
$path = realpath(Storage::disk('local')->path($document->storage_key));
@@ -195,6 +195,7 @@ class InvestmentZoneAttachmentController extends Controller
abort_unless($expected !== null && $image[2] === $expected, 422, 'O conteúdo da imagem não corresponde ao formato declarado.');
}
/** @param array<string,mixed> $metadata */
private function event(string $attachmentId, int $actor, string $action, int $version, array $metadata = []): void
{
DB::table('investment_zone_attachment_events')->insert([
@@ -17,6 +17,7 @@ use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
use Inertia\Inertia;
use Inertia\Response;
use Symfony\Component\HttpFoundation\StreamedResponse;
class InvestmentZoneController extends Controller
{
@@ -92,7 +93,7 @@ class InvestmentZoneController extends Controller
$publicColumns = array_values(array_filter($publicColumns, fn ($column) => Schema::hasColumn('investment_zones', $column)));
$rows = $zones->search($filters, $management)->orderBy('name')->orderBy('id')
->paginate(12, $management ? ['*'] : $publicColumns)->withQueryString();
$rows->getCollection()->transform(function ($zone) use ($management) {
$rows->getCollection()->transform(function (InvestmentZone $zone) use ($management) {
$territoryPath = app(InvestmentGeography::class)->path($zone->primary_territory_id, $management);
$zone->setAttribute('province', collect($territoryPath)->firstWhere('location_type', 'province')->name ?? $zone->province);
$zone->setAttribute('district', collect($territoryPath)->firstWhere('location_type', 'district')->name ?? $zone->district);
@@ -110,7 +111,7 @@ class InvestmentZoneController extends Controller
return $request->is('api/*') ? response()->json($props) : Inertia::render('investment/zones', $props);
}
public function export(Request $request, InvestmentZones $zones)
public function export(Request $request, InvestmentZones $zones): StreamedResponse
{
$filters = $request->validate(['category' => ['nullable', 'in:zee,zfi,industrial_park'], 'physical_category' => ['nullable', 'in:economic_zone,industrial_park,agro_industrial_park,industrial_logistics_park,industrial_free_zone,integrated_tourism_area,other,unknown'],
'legal_regime' => ['nullable', 'in:zee,zfi,general,pending,not_applicable'], 'development_status' => ['nullable', 'in:announced,planned,in_development,partially_operational,operational,suspended,unknown'],
@@ -123,6 +124,9 @@ class InvestmentZoneController extends Controller
return response()->streamDownload(function () use ($query): void {
$geography = app(InvestmentGeography::class);
$stream = fopen('php://output', 'wb');
if ($stream === false) {
throw new \RuntimeException('CSV output stream unavailable.');
}
fwrite($stream, "\xEF\xBB\xBF");
fputcsv($stream, ['Código', 'Nome', 'Categoria física', 'Regime', 'Desenvolvimento', 'Disponibilidade', 'Província', 'Distrito', 'Área declarada', 'Unidade', 'Fontes'], ';');
$query->chunk(200, function ($zones) use ($stream, $geography): void {
@@ -0,0 +1,74 @@
<?php
namespace App\Http\Controllers;
use App\Services\Ai\EvidenceRepository;
use App\Services\Ai\InvestorGuidance;
use App\Services\VirtualDesk;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
class InvestorGuidanceController extends Controller
{
public function contexts(Request $request, VirtualDesk $desk): JsonResponse
{
$actor = $request->user();
$organizations = DB::table('memberships')->where('user_id', $actor->id)->whereNull('deleted_at')->pluck('organization_id');
$cases = DB::table('desk_cases')->whereNull('closed_at')->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']);
return response()->json(['cases' => $cases]);
}
public function show(Request $request, string $id, InvestorGuidance $guidance): JsonResponse
{
return response()->json($guidance->view($request->user(), $id));
}
public function assumptions(Request $request, string $id, InvestorGuidance $guidance): JsonResponse|RedirectResponse
{
$guidance->assumptions($request->user(), $id, $request->all());
return $this->completed($request, 'Pressupostos corrigidos; os dados dos módulos de origem foram preservados.');
}
public function feedback(Request $request, string $id, InvestorGuidance $guidance): JsonResponse|RedirectResponse
{
$guidance->feedback($request->user(), $id, $request->all());
return $this->completed($request, 'Revisão registada. Assumir uma tarefa continua a ser uma acção separada.');
}
public function task(Request $request, string $id, InvestorGuidance $guidance): JsonResponse|RedirectResponse
{
$task = $guidance->acceptTask($request->user(), $id, $request->all());
return $request->expectsJson() ? response()->json(['id' => $task->id], 201) : back()->with('success', 'Tarefa assumida no CRM interno. Sincronização externa pendente de finalidade e autorização.');
}
public function updateTask(Request $request, string $id, string $task, InvestorGuidance $guidance): JsonResponse|RedirectResponse
{
$guidance->updateTask($request->user(), $id, $task, $request->all());
return $this->completed($request, 'Resultado da tarefa registado no mesmo histórico do dossier.');
}
public function search(Request $request, string $id, VirtualDesk $desk, EvidenceRepository $evidence): JsonResponse
{
$desk->case($request->user(), $id);
$data = $request->validate(['query' => 'required|string|min:3|max:300']);
$enabled = config('investmoz_ai.enabled') && Schema::hasTable('ai_controls') && DB::table('ai_controls')->where('capability', 'search')->where('suspended', false)->exists();
$items = $enabled ? $evidence->search($data['query'], 'pt', ['public_only' => true]) : [];
return response()->json(['mode' => 'lexical_evidence', 'model' => null, 'status' => ! $enabled ? 'unavailable' : ($items ? 'evidence_found' : 'no_evidence'),
'consulted' => $items, 'limitations' => ['Apenas excertos das fontes públicas autorizadas indexadas; não é uma consulta exaustiva nem validação jurídica.']]);
}
private function completed(Request $request, string $message): JsonResponse|RedirectResponse
{
return $request->expectsJson() ? response()->json(['message' => $message]) : back()->with('success', $message);
}
}
@@ -4,8 +4,8 @@ declare(strict_types=1);
namespace App\Http\Controllers;
use App\Jobs\ScanDocumentForMalware;
use App\Http\Requests\StoreLandAssistanceCaseRequest;
use App\Jobs\ScanDocumentForMalware;
use App\Models\InterestRequest;
use App\Models\LandAssistanceCase;
use App\Services\InterestSubmission;
@@ -8,10 +8,11 @@ use App\Models\Sector;
use App\Services\IdempotencyConflict;
use App\Services\InterestSubmission;
use App\Services\Malware\MalwareScanner;
use App\Services\VirtualDesk;
use Illuminate\Database\Query\Builder;
use Illuminate\Database\QueryException;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
@@ -47,7 +48,7 @@ class PortalServicesController extends Controller
->where('d.malware_scan_status', 'clean')->whereColumn('d.malware_scanned_sha256', 'd.sha256');
}
public function page(Request $request): Response|JsonResponse
public function page(Request $request): Response|JsonResponse|RedirectResponse
{
$page = $request->route()->defaults['page'];
if ($page === 'partnerships' && Schema::hasColumn('portal_partnerships', 'partnership_type')) {
@@ -105,12 +106,12 @@ class PortalServicesController extends Controller
}
$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();
'r.id', 'r.document_id', 'r.title', 'r.description', 'r.category', 'r.topic', 'r.document_type', 'r.document_number', 'r.document_date',
'r.applicable_from', 'r.applicable_until',
'r.source_url', 'r.language', 'r.version_label', 'r.document_group_id', 'r.territorial_scope', 'r.territory_id',
'r.source_confirmation', 'r.legal_status', 'r.rights_status', 'r.last_reviewed_at', 'r.last_reviewed_by', 'r.independent_review', 'r.metadata_version', 'r.source_reference', 'r.essential',
'd.file_name', 'd.media_type', 'd.size_bytes', 'd.issuer', 'd.issued_on', 'd.valid_until', 'd.version_no',
])->paginate(20)->withQueryString();
$pageResult->getCollection()->transform(function ($resource) {
$resource->links = DB::table('portal_resource_links')->where('resource_id', $resource->id)
->orderBy('target_type')->orderBy('target_key')->get($this->resourceLinkColumns());
@@ -153,6 +154,9 @@ class PortalServicesController extends Controller
$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']);
}
@@ -161,7 +165,7 @@ class PortalServicesController extends Controller
return Inertia::render('portal/services', $props);
}
public function recommendations(Request $request)
public function recommendations(Request $request): JsonResponse
{
$data = $request->validate([
'activity_id' => ['nullable', 'string', 'max:180'], 'province_id' => ['nullable', 'uuid'],
@@ -199,10 +203,11 @@ class PortalServicesController extends Controller
? DB::table('portal_resource_links')->where('resource_id', $resource->id)->where('target_type', 'requirement')
->where('review_status', 'confirmed')->orderBy('target_key')->get(['target_key', 'basis_reference', 'review_status'])
: collect();
return [...(array) $resource,
'download_url' => route('services.download', ['id' => $resource->id]),
'recommendation_reason' => $matched?->condition_text ?? ('Relacionado com '.$matched?->target_type.' '.$matched?->target_key.'; confirme a aplicabilidade ao seu projecto.'),
'applicability' => $matched?->applicability ?? 'support',
'recommendation_reason' => $matched->condition_text ?? ('Relacionado com '.$matched?->target_type.' '.$matched?->target_key.'; confirme a aplicabilidade ao seu projecto.'),
'applicability' => $matched->applicability ?? 'support',
'basis_reference' => $matched?->basis_reference,
'documentary_evidence' => $evidence->map(fn ($link) => ['key' => $link->target_key,
'label' => config('document-requirements.catalogue.'.$link->target_key.'.label', $link->target_key),
@@ -357,7 +362,7 @@ class PortalServicesController extends Controller
return Inertia::render('portal/manage', ['resources' => $resources,
'requirementCatalogue' => $this->requirementEvidenceEnabled()
? collect(config('document-requirements.catalogue'))->map(fn ($item, $key) => ['key' => $key, ...$item])->values()
? 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(),
@@ -548,10 +553,12 @@ class PortalServicesController extends Controller
]);
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);
@@ -565,12 +572,14 @@ class PortalServicesController extends Controller
|| ! 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;
}
@@ -600,6 +609,7 @@ class PortalServicesController extends Controller
->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,
@@ -641,6 +651,7 @@ class PortalServicesController extends Controller
}
}
/** @return array<string,mixed> */
private function validateResource(Request $request): array
{
$data = $this->validateManagement($request, [
@@ -691,6 +702,9 @@ class PortalServicesController extends Controller
/** 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
{
@@ -701,6 +715,7 @@ class PortalServicesController extends Controller
}
}
/** @param array<string,string|list<string>> $errors */
private function managementValidationException(array $errors): ValidationException
{
$exception = ValidationException::withMessages($errors);
@@ -709,6 +724,8 @@ class PortalServicesController extends Controller
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']);
@@ -725,6 +742,7 @@ class PortalServicesController extends Controller
return $data;
}
/** @param list<array<string,mixed>> $links */
private function syncResourceLinks(int $resourceId, array $links): void
{
foreach ($links as $link) {
@@ -741,7 +759,8 @@ class PortalServicesController extends Controller
}
}
private function assertPublicClassificationEligible(array $data, object $document): void
/** @param array<string,mixed> $data */
private function assertPublicClassificationEligible(array $data, \stdClass $document): void
{
if (($data['data_classification'] ?? $document->data_classification) !== 'public') {
return;
@@ -756,6 +775,7 @@ class PortalServicesController extends Controller
'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'])) {
@@ -782,6 +802,7 @@ class PortalServicesController extends Controller
&& DB::table('migrations')->where('migration', '2026_10_06_200000_add_document_requirement_evidence_links')->exists();
}
/** @return list<string> */
private function resourceLinkColumns(): array
{
$columns = ['target_type', 'target_key', 'relationship', 'applicability', 'condition_text', 'basis_reference'];
@@ -792,9 +813,10 @@ class PortalServicesController extends Controller
return $columns;
}
/** @return list<string> */
private function territoryScopeIds(string $id): array
{
return collect(DB::select(<<<'SQL'
$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
@@ -803,6 +825,15 @@ WITH RECURSIVE territory_tree AS (
WHERE child.deleted_at IS NULL AND child.data_classification = 'public'
)
SELECT id FROM territory_tree
SQL, [$id]))->pluck('id')->all() ?: [$id];
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];
}
}
+146 -53
View File
@@ -4,29 +4,41 @@ namespace App\Http\Controllers;
use App\Jobs\ScanDocumentForMalware;
use App\Models\User;
use App\Services\Ai\InvestorGuidance;
use App\Services\InstitutionalLinkMonitor;
use App\Services\InvestmentGeography;
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 Inertia\Inertia;
use Inertia\Response;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\StreamedResponse;
class VirtualDeskController extends Controller
{
public function catalogue(Request $request, VirtualDesk $desk)
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 ? 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)), mb_strtolower($filters['q'])))
$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)
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');
@@ -34,188 +46,269 @@ class VirtualDeskController extends Controller
$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)->orderByDesc('created_at')->get(['reference', 'status', 'created_at']),
'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' => DB::table('investment_submissions')->where('user_id', $actor->id)->get(['id', 'status']),
'journeys' => DB::table('investor_journeys')->where('user_id', $actor->id)->whereNull('deleted_at')->get(['id', 'dossier_id', 'status']),
'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)
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);
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)
public function showCase(Request $request, string $id, VirtualDesk $desk): JsonResponse|Response
{
$case = $desk->case($request->user(), $id); $owner = $desk->owns($request->user(), $case, false);
$case = $desk->case($request->user(), $id);
$owner = $desk->owns($request->user(), $case, false);
$this->auditRead($request, 'desk_cases', $id);
$data = ['case' => [...(array) $case, 'payload' => \Illuminate\Support\Arr::except($desk->decode($case->payload), ['_creation_hash']), 'workflow' => json_decode($case->workflow, true)],
$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), 'events' => $this->events($id, $desk), 'stepStates' => config('virtual-desk.step_states'), 'states' => config('virtual-desk.request_states'),
'links' => DB::table('desk_institutional_links')->where('status', 'active')->whereNotNull('verified_at')->get(['id', 'stage', 'entity', 'purpose', 'contact', 'verified_at']), 'source' => config('virtual-desk.source')];
'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);
}
private function documents(string $case)
/** @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']);
}
private function events(string $case, VirtualDesk $desk)
/** @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)->orderBy('e.created_at')->get(['e.id', 'e.request_id', 'e.action', 'e.payload', 'e.version', 'e.created_at', 'u.name'])->map(fn ($e) => [...(array) $e, 'payload' => $desk->decode($e->payload)]);
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)
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)
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)
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);
$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 = ['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], 'definition' => $desk->definition($row),
'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)->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.body', 'm.created_at', 'u.name'])->map(fn ($m) => [...(array) $m, 'body' => \Illuminate\Support\Facades\Crypt::decryptString($m->body)]),
'events' => $this->events($case->id, $desk)->filter(fn ($e) => $e['request_id'] === $id)->values(),
'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(\App\Services\InvestmentGeography::class)->publicReferences()->orderBy('name')->get(['id', 'name', 'location_type'])];
'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)
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)
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)
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);
$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::findOrFail($data['analyst_id']); abort_if($desk->owns($analyst, $case, false), 422, 'O proponente não pode ser o analista.');
$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)
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);
$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(\Illuminate\Support\Facades\Crypt::decryptString($existing->body) === $data['body'], 409); return; }
DB::table('desk_messages')->insert(['id' => (string) Str::uuid(), 'request_id' => $id, 'actor_id' => $request->user()->id, 'client_key' => $data['client_key'], 'body' => \Illuminate\Support\Facades\Crypt::encryptString($data['body'])]);
$desk->event($case, $request->user(), 'message_added', [], $row);
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)
public function receipt(Request $request, string $id, VirtualDesk $desk): StreamedResponse
{
$row = $desk->request($request->user(), $id); abort_unless($row->protocol && $row->submitted_snapshot, 404);
$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)
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;
$key = null;
$newId = null;
try {
DB::transaction(function () use ($request, $id, $desk, $data, &$key, &$newId) {
$case = $desk->case($request->user(), $id, true, true); 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);
$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());
$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($key, 500);
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, 404);
$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;
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();
} 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)
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.*']);
$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) && hash_equals(trim($doc->sha256), hash_file('sha256', $path)), 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)
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)
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)->where('status', 'active')->whereNotNull('verified_at')->first(); abort_unless($link, 404);
$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']);
}
@@ -0,0 +1,97 @@
<?php
namespace App\Http\Controllers;
use App\Services\VirtualDeskInstallation;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
use Inertia\Inertia;
use Inertia\Response;
class VirtualDeskInstallationController extends Controller
{
public function reviewLink(Request $request, string $id): JsonResponse|RedirectResponse
{
$data = $request->validate(['version' => 'required|integer|min:1', 'available' => 'required|boolean',
'evidence' => 'required|string|min:10|max:3000', 'contact' => 'nullable|string|max:255']);
DB::transaction(function () use ($request, $id, $data) {
$link = DB::table('desk_institutional_links')->where('id', $id)->lockForUpdate()->first();
abort_unless($link !== null, 404);
abort_unless((int) $link->version === (int) $data['version'], 409);
abort_unless(filter_var($link->url, FILTER_VALIDATE_URL) && parse_url($link->url, PHP_URL_SCHEME) === 'https', 422);
DB::table('desk_institutional_links')->where('id', $id)->update(['status' => $data['available'] ? 'active' : 'unavailable',
'contact' => $data['contact'] ?? $link->contact, 'verified_at' => now(), 'verified_by' => $request->user()->id,
'version' => $link->version + 1, 'updated_at' => now()]);
DB::table('audit_events')->insert(['source_system' => 'virtual-desk', 'entity_type' => 'desk_institutional_links', 'entity_id' => $id,
'actor_user_id' => $request->user()->id, 'action' => 'institutional_link_checked',
'after_data' => json_encode(['url' => $link->url, 'prior_version' => $link->version, ...$data, 'scope' => 'availability_only_not_legal_approval'])]);
});
return $request->expectsJson() ? response()->json(['checked' => true]) : back()->with('success', 'Conferência de disponibilidade registada por sete dias.');
}
public function sources(Request $request): JsonResponse|Response
{
$read = function (string $name): array {
$file = storage_path('app/private/virtual-desk/'.$name.'.json');
if (! is_file($file)) {
return [];
}
$bytes = file_get_contents($file);
abort_unless(is_string($bytes), 503);
return json_decode($bytes, true, 512, JSON_THROW_ON_ERROR);
};
$inventory = $read('source-inventory');
$forms = $read('form-register');
$data = ['total' => count($inventory), 'attachmentCount' => array_sum(array_map(fn ($item) => count($item['attachments'] ?? []), $inventory)),
'forms' => array_map(fn ($item) => ['identifier' => $item['identifier'], 'sha256' => $item['sha256'], 'size' => $item['size'], 'edition' => $item['edition'],
'provenance' => str_contains($item['path'], DIRECTORY_SEPARATOR.'Scan'.DIRECTORY_SEPARATOR) ? 'Acervo disponibilizado — digitalização' : 'Acervo disponibilizado — fonte de desenho ou template',
'consultation' => $item['consultation'], 'legal_documentary_status' => $item['legal_documentary_status'], 'rule_use' => $item['rule_use'], 'official_confirmation' => $item['official_confirmation'], 'fields_status' => $item['fields_status']], $forms),
'source' => config('virtual-desk.source')];
return $request->expectsJson() ? response()->json($data) : Inertia::render('desk/sources', $data);
}
public function preview(Request $request, VirtualDeskInstallation $installation): JsonResponse|Response
{
$data = $installation->preview();
return $request->expectsJson() ? response()->json($data) : Inertia::render('desk/install', $data);
}
public function apply(Request $request, VirtualDeskInstallation $installation): JsonResponse|RedirectResponse
{
$data = $request->validate(['hash' => 'required|string|size:64', 'confirm' => 'accepted']);
$installation->apply($data['hash'], $request->user());
return $request->expectsJson() ? response()->json(['installed' => true]) : back()->with('success', 'Instalação concluída; serviços ainda exigem publicação explícita.');
}
public function publishService(Request $request): JsonResponse|RedirectResponse
{
$data = $request->validate(['code' => ['required', Rule::in(array_keys(config('virtual-desk.services')))], 'confirm' => 'accepted']);
$row = DB::transaction(function () use ($request, $data) {
DB::select('select pg_advisory_xact_lock(261008, 2)');
$definition = config('virtual-desk.services.'.$data['code']);
$existing = DB::table('desk_service_versions')->where('code', $data['code'])->orderByDesc('version')->first();
if ($existing && $existing->status === 'published' && json_decode($existing->definition, true) === $definition) {
return $existing;
}
$id = (string) Str::uuid();
DB::table('desk_service_versions')->insert(['id' => $id, 'code' => $data['code'], 'version' => ($existing->version ?? 0) + 1, 'status' => 'published',
'definition' => json_encode($definition), 'source' => json_encode(config('virtual-desk.source')), 'author_id' => $request->user()->id,
'published_by' => $request->user()->id, 'published_at' => now(), 'created_at' => now(), 'updated_at' => now()]);
DB::table('audit_events')->insert(['source_system' => 'virtual-desk', 'entity_type' => 'desk_service_versions', 'entity_id' => $id, 'actor_user_id' => $request->user()->id,
'action' => 'internal_service_published', 'after_data' => json_encode(['code' => $data['code'], 'nature' => 'digital_preparation_not_official_form'])]);
return DB::table('desk_service_versions')->where('id', $id)->first();
});
return $request->expectsJson() ? response()->json(['id' => $row->id], 201) : back()->with('success', 'Serviço de preparação interna publicado.');
}
}
@@ -0,0 +1,136 @@
<?php
namespace App\Http\Controllers;
use App\Services\Ai\InvestorGuidanceEvidence;
use App\Services\VirtualDesk;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
/** Manual operations use the same requests, documents and encrypted audit history. */
class VirtualDeskOperationsController extends Controller
{
private function result(Request $request): JsonResponse|RedirectResponse
{
return $request->expectsJson() ? response()->json(['ok' => true]) : back()->with('success', 'Registo interno guardado; não constitui confirmação de uma instituição externa.');
}
private function reviewer(Request $request, VirtualDesk $desk, \stdClass $case, ?\stdClass $row): void
{
abort_unless(! $desk->owns($request->user(), $case, false) && ($request->user()->can('manage-portal') || ($row && (int) $row->analyst_id === $request->user()->id)), 403);
}
private function cleanDocument(string $caseId, string $documentId, ?string $requestId): \stdClass
{
$doc = DB::table('desk_documents as l')->join('documents as d', 'd.id', '=', 'l.document_id')
->where('l.case_id', $caseId)->where('d.id', $documentId)->whereNull('d.deleted_at')
->where(fn ($q) => $q->whereNull('l.request_id')->when($requestId, fn ($p) => $p->orWhere('l.request_id', $requestId)))
->first(['d.*', 'l.id as link_id']);
abort_unless($doc !== null && $doc->status === 'active' && $doc->malware_scan_status === 'clean' && trim($doc->sha256) === $doc->malware_scanned_sha256, 422, 'Documento indisponível para conferência: verifique autorização, integridade e análise antimalware.');
$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($hash = hash_file('sha256', $path)) && hash_equals(trim($doc->sha256), $hash), 422, 'O ficheiro original ou a sua integridade não pôde ser confirmado.');
return $doc;
}
public function documentReview(Request $request, string $id, string $document, VirtualDesk $desk): JsonResponse|RedirectResponse
{
$data = $request->validate(['version' => 'required|integer|min:1', 'request_id' => 'nullable|uuid', 'outcome' => 'required|in:internal_verified,internal_issue,contradiction_identified', 'what_checked' => 'required|string|min:3|max:3000', 'evidence' => 'required|string|min:3|max:5000', 'supports' => 'nullable|array|max:4', 'supports.*' => ['string', Rule::in(InvestorGuidanceEvidence::FIELDS)]]);
DB::transaction(function () use ($request, $id, $document, $desk, $data) {
$case = $desk->case($request->user(), $id, false, true);
abort_unless(! $case->closed_at && (int) $case->version === (int) $data['version'], 409);
$link = DB::table('desk_documents')->where('case_id', $id)->where('id', $document)->first();
abort_unless($link !== null, 404);
$requestId = $link->request_id ?: ($data['request_id'] ?? null);
$row = $requestId ? $desk->request($request->user(), $requestId, false, true) : null;
abort_if($row && $row->case_id !== $id, 422);
$this->reviewer($request, $desk, $case, $row);
$doc = $this->cleanDocument($id, $link->document_id, $requestId);
DB::table('desk_cases')->where('id', $id)->update(['version' => $case->version + 1, 'updated_at' => now()]);
$desk->event(DB::table('desk_cases')->where('id', $id)->firstOrFail(), $request->user(), 'document_reviewed', [
...$data, 'document_id' => $doc->id, 'link_id' => $link->id, 'sha256' => trim($doc->sha256), 'document_version' => (int) $doc->version_no, 'reviewer_id' => $request->user()->id,
'scope' => 'internal_documentary_check_not_official_approval_registration_or_license',
], $row);
});
return $this->result($request);
}
public function payment(Request $request, string $id, VirtualDesk $desk): JsonResponse|RedirectResponse
{
$data = $request->validate(['reference' => 'required|string|max:255', 'amount' => ['required', 'regex:/^\d{1,12}(\.\d{1,2})?$/', 'numeric', 'gt:0'], 'currency' => 'required|in:MZN,USD,EUR', 'source_reference' => 'required|string|min:3|max:3000', 'document_id' => 'nullable|uuid', 'consent' => 'accepted']);
DB::transaction(function () use ($request, $id, $desk, $data) {
$row = $desk->request($request->user(), $id, true, true);
$case = $desk->case($request->user(), $row->case_id);
abort_if(in_array($row->status, ['closed', 'cancelled'], true), 409);
if ($data['document_id'] ?? null) {
abort_unless(DB::table('desk_documents')->where('case_id', $case->id)->where('document_id', $data['document_id'])->where(fn ($q) => $q->whereNull('request_id')->orWhere('request_id', $id))->exists(), 422);
}
// References are supplied by the investor; no bank reference or fee is generated.
$existing = DB::table('desk_payments')->where('reference', $data['reference'])->first();
if ($existing) {
$parts = explode('.', (string) $data['amount']);
$amount = (ltrim($parts[0], '0') ?: '0').'.'.str_pad($parts[1] ?? '', 2, '0');
abort_unless($existing->request_id === $id && (string) $existing->amount === $amount && $existing->currency === $data['currency'] && $existing->source_reference === $data['source_reference'] && $existing->document_id === ($data['document_id'] ?? null), 409);
return;
}
$payment = (string) Str::uuid();
DB::table('desk_payments')->insert(['id' => $payment, 'request_id' => $id, 'reference' => $data['reference'], 'amount' => $data['amount'], 'currency' => $data['currency'], 'source_reference' => $data['source_reference'], 'document_id' => $data['document_id'] ?? null, 'status' => 'pending', 'created_at' => now(), 'updated_at' => now()]);
$desk->event($case, $request->user(), 'payment_declared', ['payment_id' => $payment, 'consent' => true, 'scope' => 'manual_declaration_no_bank_confirmation'], $row);
});
return $this->result($request);
}
public function paymentReview(Request $request, string $id, string $payment, VirtualDesk $desk): JsonResponse|RedirectResponse
{
$data = $request->validate(['version' => 'required|integer|min:1', 'status' => 'required|in:internal_verified,internal_issue', 'reason' => 'required|string|min:3|max:3000', 'evidence' => 'required|string|min:3|max:5000']);
DB::transaction(function () use ($request, $id, $payment, $desk, $data) {
$row = $desk->request($request->user(), $id, false, true);
$case = $desk->case($request->user(), $row->case_id);
$this->reviewer($request, $desk, $case, $row);
abort_if(in_array($row->status, ['closed', 'cancelled'], true), 409);
$record = DB::table('desk_payments')->where('request_id', $id)->where('id', $payment)->lockForUpdate()->first();
abort_unless($record !== null, 404);
abort_unless((int) $record->version === (int) $data['version'], 409);
$doc = null;
if ($data['status'] === 'internal_verified') {
abort_unless($record->document_id, 422, 'A conferência exige um comprovativo autorizado.');
$doc = $this->cleanDocument($case->id, $record->document_id, $id);
}
DB::table('desk_payments')->where('id', $payment)->update(['status' => $data['status'], 'reason' => $data['reason'], 'verified_by' => $request->user()->id, 'version' => $record->version + 1, 'updated_at' => now()]);
$desk->event($case, $request->user(), 'payment_reviewed', [...$data, 'payment_id' => $payment, 'document_id' => $doc?->id, 'sha256' => $doc ? trim($doc->sha256) : null, 'reviewer_id' => $request->user()->id, 'scope' => 'internal_proof_check_no_bank_confirmation'], $row);
});
return $this->result($request);
}
public function communication(Request $request, string $id, VirtualDesk $desk): JsonResponse|RedirectResponse
{
$data = $request->validate(['channel' => 'required|in:phone,email,sms,whatsapp,in_person', 'direction' => 'required|in:received,sent', 'occurred_at' => 'required|date|before_or_equal:now', 'summary' => 'required|string|min:3|max:5000', 'evidence' => 'required|string|min:3|max:3000', '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_unless($desk->owns($request->user(), $case) || $request->user()->can('manage-portal') || (int) $row->analyst_id === $request->user()->id, 403);
abort_if(in_array($row->status, ['closed', 'cancelled'], true), 409);
foreach (DB::table('desk_events')->where('request_id', $id)->where('actor_id', $request->user()->id)->where('action', 'manual_communication_recorded')->get(['payload']) as $event) {
$saved = $desk->decode($event->payload);
if (($saved['client_key'] ?? null) === $data['client_key']) {
abort_unless(array_intersect_key($saved, $data) === $data, 409);
return;
}
}
$desk->event($case, $request->user(), 'manual_communication_recorded', [...$data, 'scope' => 'manual_record_not_portal_delivery_confirmation'], $row);
});
return $this->result($request);
}
}