self::DISCLAIMER]); } public function blank(): Response { return Inertia::render('land-assistance/blank', ['disclaimer' => self::DISCLAIMER]); } public function index(Request $request): Response { $cases = LandAssistanceCase::where('user_id', $request->user()->id) ->latest('updated_at')->paginate(20)->through(fn (LandAssistanceCase $case): array => [ 'id' => $case->id, 'reference' => $case->reference, 'status' => $case->status, 'version' => $case->version, 'updated_at' => $case->updated_at, ]); return Inertia::render('land-assistance/workspace', ['cases' => $cases]); } public function create(Request $request): Response { $context = $request->validate(['context' => ['nullable', 'uuid']])['context'] ?? null; $dossier = null; if ($context !== null) { $case = app(VirtualDesk::class)->case($request->user(), $context); abort_unless(config('investor-path.enabled') && (int) $case->user_id === $request->user()->id && ! $case->closed_at && $case->journey_id && DB::table('investor_journeys')->where('id', $case->journey_id)->whereNotNull('path_definition_id')->whereNull('deleted_at')->exists(), 403); $dossier = ['id' => $case->id, 'title' => $case->title, 'url' => '/investimentos/'.$case->id]; } return Inertia::render('land-assistance/form', [ 'case' => null, 'boundContextRevision' => null, 'returnDossier' => $dossier, 'user' => ['name' => $request->user()->name, 'email' => $request->user()->email], 'disclaimer' => self::DISCLAIMER, 'modelVersion' => 'land-assistance-v1', 'countries' => app(CountryCatalogue::class)->all(), ]); } public function edit(Request $request, string $id): Response { $case = $this->owned($request, $id); abort_unless(in_array($case->status, ['draft', 'waiting_applicant'], true), 409, 'Este pedido não está aberto para edição.'); return Inertia::render('land-assistance/form', [ 'case' => $case->only(['id', 'reference', 'version', 'status', 'payload']), 'boundContextRevision' => app(InvestorContextConsumers::class)->landRevision($case->id), 'returnDossier' => $this->dossierContext($case), 'user' => ['name' => $request->user()->name, 'email' => $request->user()->email], 'disclaimer' => self::DISCLAIMER, 'modelVersion' => 'land-assistance-v1', 'countries' => app(CountryCatalogue::class)->all(), ]); } public function store(StoreLandAssistanceCaseRequest $request): RedirectResponse|JsonResponse { $payload = $request->payload(); $payload['full_name'] = $payload['full_name'] ?? $request->user()->name; $payload['email'] = $payload['email'] ?? $request->user()->email; $case = DB::transaction(function () use ($request, $payload): LandAssistanceCase { $case = LandAssistanceCase::create([ 'user_id' => $request->user()->id, 'reference' => 'TERRA-'.now()->format('Y').'-'.Str::upper(Str::random(8)), 'status' => 'draft', 'version' => 1, 'payload' => $payload, 'crm_submission_key' => (string) Str::uuid(), ]); $this->revision($case); $this->event($case, $request, 'draft_created'); return $case; }); return $request->expectsJson() ? response()->json(['id' => $case->id, 'reference' => $case->reference, 'version' => $case->version], 201) : redirect()->route('land-assistance.show', $case->id); } public function show(Request $request, string $id): Response|JsonResponse { $case = $this->owned($request, $id); $data = [ ...$this->presentation($case), 'case' => $case->only(['id', 'reference', 'status', 'version', 'payload', 'updated_at']), 'documents' => DB::table('land_assistance_documents as link')->join('documents as document', 'document.id', '=', 'link.document_id') ->where('link.case_id', $id)->whereIn('document.status', ['active', 'quarantined'])->whereNull('document.deleted_at') ->get(['link.id', 'link.category', 'document.file_name', 'document.status']), 'events' => DB::table('land_assistance_events')->where('case_id', $id)->orderBy('created_at') ->get(['action', 'version', 'metadata', 'created_at']), 'supportTicket' => $case->interest_request_id ? InterestRequest::query()->whereKey($case->interest_request_id)->first(['reference', 'status', 'created_at']) : null, 'disclaimer' => self::DISCLAIMER, 'management' => false, ]; return $request->expectsJson() ? response()->json($data) : Inertia::render('land-assistance/case', $data); } public function save(StoreLandAssistanceCaseRequest $request, string $id): RedirectResponse|JsonResponse { $data = $request->validated(); $payload = $request->payload(); $case = DB::transaction(function () use ($request, $id, $data, $payload): LandAssistanceCase { $case = $this->owned($request, $id, true); abort_unless(in_array($case->status, ['draft', 'waiting_applicant'], true), 409, 'Este pedido está bloqueado para edição pelo requerente.'); $this->checkVersion($case, (int) $data['version']); $payload['full_name'] = $payload['full_name'] ?? $case->payload['full_name'] ?? $request->user()->name; $payload['email'] = $payload['email'] ?? $case->payload['email'] ?? $request->user()->email; $case->forceFill(['payload' => $payload, 'version' => $case->version + 1])->save(); $this->revision($case); $this->event($case, $request, 'draft_updated'); return $case; }); return $request->expectsJson() ? response()->json(['id' => $case->id, 'reference' => $case->reference, 'version' => $case->version]) : redirect()->route('land-assistance.show', $case->id)->with('status', 'Rascunho guardado.'); } public function requestSupport(StoreLandAssistanceCaseRequest $request, string $id, InterestSubmission $submission): RedirectResponse|JsonResponse { $data = $request->validated(); $submittedPayload = $request->payload(); $case = DB::transaction(function () use ($request, $id, $data, $submission, $submittedPayload): LandAssistanceCase { if ($boundId = $submittedPayload['investor_path_case_id'] ?? null) { app(InvestorContextConsumers::class)->assertReviewed($request->user(), $boundId, $request->all(), app(InvestorContextConsumers::class)->landRevision($id)); } $case = $this->owned($request, $id, true); $replaying = $case->status === 'support_requested'; abort_unless(in_array($case->status, ['draft', 'waiting_applicant', 'support_requested'], true), 409, 'Este pedido já não pode ser submetido neste estado.'); if (! $replaying) { $this->checkVersion($case, (int) $data['version']); } $payload = [...$case->payload, ...$submittedPayload]; $payload['full_name'] = $payload['full_name'] ?? $request->user()->name; if (! array_key_exists('email', $payload)) { $payload['email'] = $request->user()->email; } $payload['email'] = is_string($payload['email']) && trim($payload['email']) !== '' ? strtolower(trim($payload['email'])) : null; abort_if(empty($payload['email']) && empty($payload['phone']), 422, 'Indique pelo menos um contacto valido.'); if ($replaying) { // A retry of the same accepted attempt recovers its original receipt. // Changed data, another version or a missing receipt must not reopen the case. abort_unless((int) $case->version === (int) $data['version'] + 1 && $case->interest_request_id && hash_equals(hash('sha256', json_encode(Arr::sortRecursive($case->payload), JSON_THROW_ON_ERROR)), hash('sha256', json_encode(Arr::sortRecursive($payload), JSON_THROW_ON_ERROR))), 409, 'O pedido mudou; recupere o estado atual antes de voltar a enviar.'); } $message = trim(($payload['request_description'] ?? 'Pedido de apoio sobre Terra e DUAT.')."\nReferência interna: {$case->reference}. Tipo: ".($payload['support_kind'] ?? 'não indicado').'.'); $interest = $submission->receive([ 'kind' => 'support', 'fullName' => $payload['full_name'], 'email' => $payload['email'], 'phone' => $payload['phone'] ?? null, 'country' => $payload['country'] ?? 'Não indicado', 'sectorInterest' => 'Terra e DUAT', 'message' => mb_substr($message, 0, 2000), 'privacyConsent' => true, 'portal_user_id' => $request->user()->id, 'land_case_id' => $case->id, 'land_case_reference' => $case->reference, 'declared_origin_country_code' => $payload['declared_origin_country_code'] ?? null, ], $case->crm_submission_key, fn () => InterestRequest::create([ 'reference' => 'AP-'.now()->year.'-'.Str::ulid(), 'full_name' => $payload['full_name'], 'email' => $payload['email'], 'phone' => $payload['phone'] ?? null, 'country' => $payload['country'] ?? 'Não indicado', // Use the existing intake vocabulary; retain the more specific declared category. // This factory only runs for a new receipt. Older submissions/replay hashes stay intact. 'investor_type' => match ($payload['applicant_category'] ?? null) { 'individual' => 'individual', 'entity' => 'empresa', default => 'outro', }, 'sector_interest' => 'Terra e DUAT', 'message' => mb_substr($message, 0, 2000), 'privacy_consent' => true, 'consented_at' => now(), 'status' => 'received', 'source_page' => '/terra-duat', 'metadata' => ['kind' => 'support', 'portal_user_id' => $request->user()->id, 'land_case_id' => $case->id, 'land_case_reference' => $case->reference, 'applicant_category' => $payload['applicant_category'] ?? null, 'declared_origin_country_code' => $payload['declared_origin_country_code'] ?? null], ])); if ($replaying) { abort_unless((int) $interest->getKey() === (int) $case->interest_request_id && ! $interest->trashed(), 409, 'O recibo original não está disponível; recupere o estado do pedido.'); return $case; } $case->forceFill([ 'payload' => $payload, 'status' => 'support_requested', 'support_requested_at' => now(), 'version' => $case->version + 1, 'interest_request_id' => $interest->id, ])->save(); $this->revision($case); $this->event($case, $request, 'support_requested'); return $case; }); return $request->expectsJson() ? response()->json(['id' => $case->id, 'reference' => $case->reference, 'status' => $case->status, 'version' => $case->version], 202) : redirect()->route('land-assistance.show', $case->id)->with('status', 'Pedido de apoio registado no InvestMoz. Isto não é submissão oficial de DUAT.'); } public function reportUpdate(Request $request, string $id): RedirectResponse|JsonResponse { $data = $request->validate([ 'version' => ['required', 'integer', 'min:1'], 'reported_status' => ['required', 'string', 'max:1000'], 'official_reference' => ['nullable', 'string', 'max:160'], 'receiving_entity' => ['nullable', 'string', 'max:200'], 'reported_at' => ['nullable', 'date_format:Y-m-d'], ]); $case = DB::transaction(function () use ($request, $id, $data): LandAssistanceCase { $case = $this->owned($request, $id, true); abort_unless(in_array($case->status, ['support_requested', 'internal_review', 'waiting_applicant', 'referral_prepared'], true), 409); $this->checkVersion($case, (int) $data['version']); $payload = $case->payload; $updates = $payload['reported_updates'] ?? []; $updates[] = ['reported_status' => $data['reported_status'], 'official_reference' => $data['official_reference'] ?? null, 'receiving_entity' => $data['receiving_entity'] ?? null, 'reported_at' => $data['reported_at'] ?? null, 'recorded_at' => now()->toIso8601String()]; $payload['reported_updates'] = array_slice($updates, -20); $case->forceFill(['payload' => $payload, 'version' => $case->version + 1])->save(); $this->revision($case); $this->event($case, $request, 'applicant_update_recorded'); return $case; }); return $request->expectsJson() ? response()->json(['version' => $case->version, 'reported_by_applicant' => true]) : redirect()->route('land-assistance.show', $case->id)->with('status', 'Actualização registada como informação comunicada por si.'); } public function attach(Request $request, string $id): RedirectResponse|JsonResponse { $data = $request->validate([ 'version' => ['required', 'integer', 'min:1'], 'category' => ['required', Rule::in(['sketch', 'prior_right', 'exploration_plan', 'official_receipt', 'consultation_minutes', 'photo', 'other'])], 'file' => ['required', 'file', 'mimes:pdf,jpg,jpeg,png', 'max:10240'], ]); $case = $this->owned($request, $id, true); $this->checkVersion($case, (int) $data['version']); abort_unless(! in_array($case->status, ['closed'], true), 409); abort_if(DB::table('land_assistance_documents')->where('case_id', $id)->count() >= 20, 422, 'Máximo de vinte documentos por pedido.'); $file = $request->file('file'); $documentId = (string) Str::uuid(); $key = 'land-assistance/'.$id.'/'.$documentId; $stored = $file->storeAs('land-assistance/'.$id, $documentId, 'local'); if (! is_string($stored) || $stored === '') { throw ValidationException::withMessages(['file' => 'Não foi possível guardar o ficheiro em armazenamento privado.']); } try { DB::transaction(function () use ($request, $case, $data, $file, $documentId, $stored): void { $locked = $this->owned($request, $case->id, true); $this->checkVersion($locked, (int) $data['version']); DB::table('documents')->insert([ 'id' => $documentId, 'source_system' => 'land-assistance', 'data_classification' => 'restricted', 'file_name' => basename($file->getClientOriginalName()), 'media_type' => (string) $file->getMimeType(), 'storage_key' => $stored, 'sha256' => hash_file('sha256', Storage::disk('local')->path($stored)), 'size_bytes' => $file->getSize(), 'uploaded_by' => $request->user()->id, // The configured scanner releases only the exact original hash after a clean result. 'status' => 'quarantined', 'created_at' => now(), 'updated_at' => now(), ]); DB::table('land_assistance_documents')->insert([ 'id' => (string) Str::uuid(), 'case_id' => $locked->id, 'document_id' => $documentId, 'category' => $data['category'], 'created_at' => now(), ]); $locked->forceFill(['version' => $locked->version + 1])->save(); $this->revision($locked); $this->event($locked, $request, 'attachment_added', ['document_id' => $documentId, 'category' => $data['category']]); }); } catch (\Throwable $exception) { Storage::disk('local')->delete($stored); throw $exception; } ScanDocumentForMalware::dispatch($documentId)->afterCommit(); return $request->expectsJson() ? response()->json(['uploaded' => true], 201) : back()->with('status', 'Documento privado anexado; a revisão ainda está pendente.'); } public function download(Request $request, string $id, string $attachment): \Illuminate\Http\Response|BinaryFileResponse { // Management uses its existing explicit permission; the owner URL stays owner-only. if ($request->routeIs('land-assistance.manage.download')) { abort_unless($request->user()->can('manage-portal'), 403); $case = LandAssistanceCase::whereKey($id)->firstOrFail(); } else { $case = $this->owned($request, $id); } $document = DB::table('land_assistance_documents as link')->join('documents as document', 'document.id', '=', 'link.document_id') ->where('link.case_id', $id)->where('link.id', $attachment)->where('document.status', 'active')->whereNull('document.deleted_at') ->where('document.malware_scan_status', 'clean')->whereColumn('document.malware_scanned_sha256', 'document.sha256') ->first(['document.*']); abort_if($document === null, 404); $response = app(VerifiedDocumentBinary::class)->respond($document); DB::table('audit_events')->insert(['source_system' => 'land-assistance', 'entity_type' => 'documents', 'entity_id' => $document->id, 'actor_user_id' => $request->user()->id, 'action' => 'document_downloaded', 'data_classification' => 'restricted', 'after_data' => json_encode(['case_id' => $case->id, 'attachment_id' => $attachment], JSON_THROW_ON_ERROR)]); return $response; } public function print(Request $request, string $id): Response { $case = $this->owned($request, $id); return Inertia::render('land-assistance/print', [ 'case' => $case->only(['reference', 'version', 'payload', 'updated_at']), 'disclaimer' => self::DISCLAIMER, 'modelVersion' => 'land-assistance-v1', 'country' => app(CountryCatalogue::class)->describe($case->payload['country'] ?? null), 'declaredOrigin' => app(CountryCatalogue::class)->describe($case->payload['declared_origin_country_code'] ?? null), ]); } /** @return array */ private function presentation(LandAssistanceCase $case, bool $management = false): array { $payload = $case->payload; $countries = app(CountryCatalogue::class); $country = $countries->describe($payload['country'] ?? null); $paths = []; foreach ($payload['parcels'] ?? [] as $parcel) { $id = $parcel['locality_id'] ?? $parcel['admin_post_id'] ?? $parcel['district_id'] ?? $parcel['province_id'] ?? null; $paths[] = app(InvestmentGeography::class)->path($id ?: null, $management); } return ['countryName' => $country['code'] !== null ? $country['name'] : ($payload['country'] ?? 'Não informado'), 'declaredOrigin' => $countries->describe($payload['declared_origin_country_code'] ?? null), 'territoryPaths' => $paths, 'returnDossier' => $this->dossierContext($case)]; } /** * @return array|null */ private function dossierContext(LandAssistanceCase $case): ?array { $id = $case->payload['investor_path_case_id'] ?? null; $actor = request()->user(); if (! $actor || (int) $case->user_id !== $actor->id || ! is_string($id) || ! Str::isUuid($id) || ! config('investor-path.enabled')) { return null; } $dossier = DB::table('desk_cases')->where('id', $id)->where('user_id', $actor->id)->first(); return $dossier ? ['id' => $dossier->id, 'title' => $dossier->title, 'url' => '/investimentos/'.$dossier->id] : null; } public function manage(Request $request): Response { $cases = LandAssistanceCase::query()->latest('updated_at')->paginate(30)->through(fn (LandAssistanceCase $case): array => [ 'id' => $case->id, 'reference' => $case->reference, 'status' => $case->status, 'version' => $case->version, 'updated_at' => $case->updated_at, 'applicant' => $case->payload['full_name'] ?? null, ]); return Inertia::render('land-assistance/management', ['cases' => $cases]); } public function manageShow(Request $request, string $id): Response { $case = LandAssistanceCase::findOrFail($id); $events = DB::table('land_assistance_events')->where('case_id', $id)->orderBy('created_at')->get(['action', 'version', 'metadata', 'created_at']); return Inertia::render('land-assistance/case', [ ...$this->presentation($case, true), 'case' => $case->only(['id', 'reference', 'status', 'version', 'payload', 'updated_at']), 'documents' => DB::table('land_assistance_documents as link')->join('documents as document', 'document.id', '=', 'link.document_id') ->where('link.case_id', $id)->whereIn('document.status', ['active', 'quarantined'])->whereNull('document.deleted_at')->get(['link.id', 'link.category', 'document.file_name', 'document.status']), 'events' => $events, 'supportTicket' => $case->interest_request_id ? InterestRequest::query()->whereKey($case->interest_request_id)->first(['reference', 'status', 'created_at']) : null, 'disclaimer' => self::DISCLAIMER, 'management' => true, ]); } public function changeStatus(Request $request, string $id): RedirectResponse|JsonResponse { $data = $request->validate([ 'version' => ['required', 'integer', 'min:1'], 'status' => ['required', Rule::in(['internal_review', 'waiting_applicant', 'referral_prepared', 'closed'])], 'evidence_reference' => ['nullable', 'string', 'max:300'], ]); $case = DB::transaction(function () use ($request, $id, $data): LandAssistanceCase { $case = LandAssistanceCase::whereKey($id)->lockForUpdate()->firstOrFail(); $this->checkVersion($case, (int) $data['version']); $allowed = [ 'support_requested' => ['internal_review', 'waiting_applicant', 'referral_prepared', 'closed'], 'internal_review' => ['waiting_applicant', 'referral_prepared', 'closed'], 'waiting_applicant' => ['internal_review', 'referral_prepared', 'closed'], 'referral_prepared' => ['internal_review', 'waiting_applicant', 'closed'], ]; abort_unless(in_array($data['status'], $allowed[$case->status] ?? [], true), 409, 'Transição de estado não permitida.'); if ($data['status'] === 'referral_prepared' && empty($data['evidence_reference'])) { throw ValidationException::withMessages(['evidence_reference' => 'Registe a referência interna do material preparado para encaminhamento.']); } $previousStatus = $case->status; $case->forceFill(['status' => $data['status'], 'version' => $case->version + 1])->save(); $this->revision($case); $this->event($case, $request, 'status_changed', [ 'from' => $previousStatus, 'to' => $data['status'], 'evidence_reference' => $data['evidence_reference'] ?? null, 'external_submission_confirmed' => false, ]); return $case; }); return $request->expectsJson() ? response()->json(['status' => $case->status, 'version' => $case->version]) : redirect()->route('land-assistance.manage.show', $case->id)->with('status', 'Estado interno actualizado. Nenhuma recepção pública foi presumida.'); } private function owned(Request $request, string $id, bool $lock = false): LandAssistanceCase { $query = LandAssistanceCase::whereKey($id)->where('user_id', $request->user()->id); return ($lock ? $query->lockForUpdate() : $query)->firstOrFail(); } private function checkVersion(LandAssistanceCase $case, int $expected): void { abort_unless($case->version === $expected, 412, 'Este rascunho foi alterado. Recarregue antes de continuar.'); } /** @param array $metadata */ private function event(LandAssistanceCase $case, Request $request, string $action, array $metadata = []): void { $contextId = $case->payload['investor_path_case_id'] ?? null; if ($contextId) { $binding = app(InvestorContextConsumers::class)->landRevision($case->id); if ($action === 'draft_created' || $request->boolean('context_reviewed')) { $binding = app(InvestorContextConsumers::class)->assertReviewed($request->user(), $contextId, $action === 'draft_created' ? ['context_reviewed' => true, 'context_revision' => app(InvestorContext::class)->state(app(VirtualDesk::class)->case($request->user(), $contextId))['revision']] : $request->all(), $binding); } $metadata['investment_context_revision'] = $binding; } DB::table('land_assistance_events')->insert([ 'case_id' => $case->id, 'actor_id' => $request->user()->id, 'action' => $action, 'version' => $case->version, 'metadata' => json_encode($metadata, JSON_THROW_ON_ERROR), 'created_at' => now(), ]); } private function revision(LandAssistanceCase $case): void { DB::table('land_assistance_revisions')->insert([ 'case_id' => $case->id, 'version' => $case->version, 'snapshot' => Crypt::encryptString(json_encode(['status' => $case->status, 'payload' => $case->payload], JSON_THROW_ON_ERROR)), 'created_at' => now(), ]); } }