@@ -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];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user