403 lines
22 KiB
PHP
403 lines
22 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Http\Controllers;
|
|
|
|
use App\Jobs\ScanDocumentForMalware;
|
|
use App\Http\Requests\StoreLandAssistanceCaseRequest;
|
|
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(),
|
|
]);
|
|
}
|
|
}
|