Files
Investidor-V2/app/Http/Controllers/LandAssistanceController.php
T
nala 1106509116
tests / ci (push) Canceled after 0s
vrsao 2
2026-10-08 14:02:01 +02:00

403 lines
22 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use App\Http\Requests\StoreLandAssistanceCaseRequest;
use App\Jobs\ScanDocumentForMalware;
use App\Models\InterestRequest;
use App\Models\LandAssistanceCase;
use App\Services\InterestSubmission;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
use Inertia\Inertia;
use Inertia\Response;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
class LandAssistanceController extends Controller
{
private const DISCLAIMER = 'Este documento apoia a preparação e o acompanhamento. Não constitui requerimento oficialmente recebido, título de DUAT, confirmação de disponibilidade da terra ou decisão da autoridade competente.';
public function guide(): Response
{
return Inertia::render('land-assistance/guide', ['disclaimer' => 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
{
return Inertia::render('land-assistance/form', [
'case' => null,
'user' => ['name' => $request->user()->name, 'email' => $request->user()->email],
'disclaimer' => self::DISCLAIMER,
'modelVersion' => 'land-assistance-v1',
]);
}
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']),
'user' => ['name' => $request->user()->name, 'email' => $request->user()->email],
'disclaimer' => self::DISCLAIMER,
'modelVersion' => 'land-assistance-v1',
]);
}
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 = [
'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 {
$case = $this->owned($request, $id, true);
abort_unless(in_array($case->status, ['draft', 'waiting_applicant'], true), 409, 'Este pedido já não pode ser submetido neste estado.');
$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.');
$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,
], $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', 'investor_type' => 'individual',
'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],
]));
$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)),
// No antivirus engine is configured in this project; fail closed until an operator scans it.
'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): BinaryFileResponse
{
$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.file_name', 'document.storage_key', 'document.sha256', 'document.malware_scanned_sha256']);
abort_if($document === null, 404);
$root = realpath(Storage::disk('local')->path(''));
$path = realpath(Storage::disk('local')->path($document->storage_key));
abort_unless($root && $path && str_starts_with($path, $root.DIRECTORY_SEPARATOR) && is_file($path), 404);
$checksum = hash_file('sha256', $path);
abort_unless(is_string($checksum) && hash_equals($document->sha256, $checksum)
&& hash_equals($document->sha256, (string) $document->malware_scanned_sha256), 404);
return response()->download($path, basename($document->file_name), ['Cache-Control' => 'private, no-store', 'X-Content-Type-Options' => 'nosniff']);
}
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',
]);
}
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', [
'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<string,mixed> $metadata */
private function event(LandAssistanceCase $case, Request $request, string $action, array $metadata = []): void
{
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(),
]);
}
}