fix: complete InvesteMoz validation and error corrections

This commit is contained in:
2026-10-29 21:47:33 +02:00
parent 63147312e2
commit daf8623441
718 changed files with 127308 additions and 20959 deletions
@@ -4,7 +4,7 @@ namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Models\Opportunity;
use App\Models\Sector;
use App\Services\SectorOrganization;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
@@ -21,7 +21,7 @@ class HomeController extends Controller
$locale = $validated['locale'] ?? 'pt';
$data = Cache::remember("portal_home_sector_organization_v1_{$locale}", now()->addMinutes(10), function () {
$sectors = \App\Services\SectorOrganization::catalog();
$sectors = SectorOrganization::catalog();
$featured = Opportunity::with('sector:id,name,slug')
->published()->where('is_featured', true)
@@ -8,11 +8,13 @@ use App\Http\Requests\StoreInterestRequestRequest;
use App\Jobs\NotifyInterestReceived;
use App\Models\ExistingBusiness;
use App\Models\InterestRequest;
use App\Models\InvestmentSubmission;
use App\Models\Opportunity;
use App\Services\IdempotencyConflict;
use App\Services\InterestSubmission;
use Illuminate\Database\QueryException;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Facades\Validator;
@@ -26,7 +28,7 @@ class InterestRequestController extends Controller
$validated = [...$request->validated(), '_actor_id' => $request->user()->id];
$key = $request->header('Idempotency-Key');
abort_unless(is_string($key), 422, 'Identificador do envio em falta.');
Validator::make(['idempotency_key' => $key], ['idempotency_key' => [$key === null ? 'nullable' : 'required', 'uuid']])->validate();
Validator::make(['idempotency_key' => $key], ['idempotency_key' => ['required', 'uuid']])->validate();
try {
$interestRequest = $submission->receive($validated, $key, function () use ($request, $validated) {
@@ -78,19 +80,20 @@ class InterestRequestController extends Controller
}
}
$row = \App\Models\InvestmentSubmission::create([
$row = InvestmentSubmission::create([
'user_id' => $request->user()->id, 'opportunity_id' => $opportunity?->id,
'interest_request_id' => $interest->id, 'status' => 'submitted', 'version' => 1,
'payload' => ['simple_participation' => true, 'opportunity_title' => $opportunity?->title ?? $business?->title, 'opportunity_reference' => $opportunity?->reference, 'phone' => $validated['phone'] ?? null, 'declared_origin_country_code' => $validated['declared_origin_country_code'] ?? null, 'full_name' => $validated['fullName'], 'email' => $validated['email'],
'payload' => ['simple_participation' => true, 'opportunity_title' => $opportunity->title ?? $business?->public_title, 'opportunity_reference' => $opportunity?->reference, 'phone' => $validated['phone'] ?? null, 'declared_origin_country_code' => $validated['declared_origin_country_code'] ?? null, 'full_name' => $validated['fullName'], 'email' => $validated['email'],
'objective' => $validated['message'], 'sector' => $validated['sectorInterest'],
'country' => $validated['country'], 'existing_business_id' => $business?->id,
'modality' => $validated['modality'] ?? null],
]);
\Illuminate\Support\Facades\DB::table('investment_submission_events')->insert([
DB::table('investment_submission_events')->insert([
'submission_id' => $row->id, 'actor_id' => $request->user()->id,
'action' => 'submitted', 'version' => 1, 'created_at' => now(),
]);
$interest->forceFill(['metadata' => [...$interest->metadata, 'submission_id' => $row->id]])->save();
return $interest;
});
@@ -99,7 +102,8 @@ class InterestRequestController extends Controller
'message' => 'Manifestação de interesse registada com sucesso.',
'data' => [
'reference' => $interestRequest->reference,
'status' => $interestRequest->status,
// The POST acknowledgement is stable on replay; current workflow state is in tracking.
'status' => InterestStatus::Received,
'tracking_url' => '/investidor/manifestacoes',
],
], 201);
@@ -3,7 +3,10 @@
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Models\Sector;
use App\Services\CountryCatalogue;
use App\Services\EconomicReferenceCatalog;
use App\Services\SectorOrganization;
use Illuminate\Database\Query\Builder;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
@@ -44,7 +47,9 @@ class ReferenceCatalogController extends Controller
$cae = $catalog === 'economic-activities' && ($data['origin'] ?? 'portal') === 'cae';
$versionQuery = DB::table('reference_catalog_versions')->where('catalog_code', $cae ? 'economic-activities-cae' : $catalog)->where('locale', 'pt-MZ');
if ($cae && ! app(\App\Services\EconomicReferenceCatalog::class)->available()) { $versionQuery->whereRaw('false'); }
if ($cae && ! app(EconomicReferenceCatalog::class)->available()) {
$versionQuery->whereRaw('false');
}
if (isset($data['version'])) {
$versionQuery->where('version_no', $data['version'])->whereIn('status', ['published', 'retired']);
} else {
@@ -82,7 +87,7 @@ class ReferenceCatalogController extends Controller
$page = $query->orderBy('code')->orderBy('id')->paginate($data['per_page'] ?? 20)->withQueryString();
$items = collect($page->items())->map(fn (object $row): array => $this->present($catalog, (array) $row))->values();
if ($cae) {
$paths = app(\App\Services\EconomicReferenceCatalog::class)->activities()->keyBy('id');
$paths = app(EconomicReferenceCatalog::class)->activities()->keyBy('id');
$items = $items->map(fn ($row) => [...$row, 'level' => $paths[$row['id']]->level, 'path' => $paths[$row['id']]->path, 'source_version' => $paths[$row['id']]->source_version, 'edition' => 'not_determined', 'divergence' => '600/579 documentalmente aberta']);
}
@@ -169,15 +174,17 @@ class ReferenceCatalogController extends Controller
->where('valid_from', '<=', today()->toDateString())
->where(fn (Builder $builder) => $builder->whereNull('valid_to')->orWhere('valid_to', '>', today()->toDateString()))
->select(['id', 'code', 'label_pt_mz as name', 'parent_id', 'sector_id', 'version_no']);
if (empty($data['version'])) $query->where('id', '!=', \App\Services\SectorOrganization::LEGACY_ACTIVITY_ID);
if (empty($data['version'])) {
$query->where('id', '!=', SectorOrganization::LEGACY_ACTIVITY_ID);
}
if (array_key_exists('parent_id', $data) && $data['parent_id'] !== null) {
$query->where('parent_id', $data['parent_id']);
} else {
$query->whereNull('parent_id');
}
if (! empty($data['sector_id'])) {
$sector = \App\Models\Sector::find($data['sector_id']);
$query->whereIn('sector_id', $sector ? \App\Models\Sector::whereIn('slug', \App\Services\SectorOrganization::slugs($sector->slug))->pluck('id') : []);
$sector = Sector::whereKey($data['sector_id'])->first();
$query->whereIn('sector_id', $sector ? Sector::whereIn('slug', SectorOrganization::slugs($sector->slug))->pluck('id') : []);
}
if (! empty($data['q'])) {
$this->search($query, $data['q'], ['code', 'label_pt_mz']);
@@ -4,13 +4,14 @@ namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Models\Sector;
use App\Services\SectorOrganization;
use Illuminate\Http\JsonResponse;
class SectorController extends Controller
{
public function index(): JsonResponse
{
$sectors = \App\Services\SectorOrganization::catalog()->map(fn ($s) => [
$sectors = SectorOrganization::catalog()->map(fn ($s) => [
'id' => $s->id, 'name' => $s->name, 'slug' => $s->slug,
'shortDescription' => $s->short_description, 'description' => $s->description,
'icon' => $s->icon, 'opportunitiesCount' => $s->opportunities_count,
@@ -25,7 +26,8 @@ class SectorController extends Controller
$sector = Sector::with(['opportunities' => fn ($q) => $q->published()])
->where('slug', $slug)->firstOrFail();
$sector->setRelation('opportunities', \App\Services\SectorOrganization::opportunities($sector)->get());
return response()->json(['success' => true, 'data' => $sector, 'canonical_url' => \App\Services\SectorOrganization::href($slug), 'specialization_of' => $slug === 'fintech' ? 'mercado-financeiro' : null]);
$sector->setRelation('opportunities', SectorOrganization::opportunities($sector)->get());
return response()->json(['success' => true, 'data' => $sector, 'canonical_url' => SectorOrganization::href($slug), 'specialization_of' => $slug === 'fintech' ? 'mercado-financeiro' : null]);
}
}
@@ -5,14 +5,17 @@ namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Jobs\ScanDocumentForMalware;
use App\Services\SimulationFormDrafts;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use stdClass;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
class SimulationOfferDocumentController extends Controller
{
public function store(Request $request, string $id, SimulationFormDrafts $drafts)
public function store(Request $request, string $id, SimulationFormDrafts $drafts): JsonResponse
{
abort_unless(Str::isUuid($id), 404);
$drafts->find($id, $request);
@@ -20,6 +23,7 @@ class SimulationOfferDocumentController extends Controller
$request->validate(['file' => ['required', 'file', 'mimetypes:application/pdf,text/plain,image/png,image/jpeg', 'max:10240']]);
$file = $request->file('file');
$hash = hash_file('sha256', $file->getRealPath());
abort_unless(is_string($hash), 422, 'Não foi possível verificar o conteúdo do anexo.');
$existing = DB::table('documents')->where('source_system', 'simulation-fiscal-offer')->where('external_id', $id)->where('sha256', $hash)->whereNull('deleted_at')->first();
if ($existing) {
return response()->json(['data' => $this->metadata($id, $existing)], 200)->header('Cache-Control', 'private, no-store');
@@ -37,32 +41,37 @@ class SimulationOfferDocumentController extends Controller
throw $e;
}
ScanDocumentForMalware::dispatch($documentId)->afterCommit();
return response()->json(['data' => $this->metadata($id, DB::table('documents')->where('id', $documentId)->first())], 201)->header('Cache-Control', 'private, no-store');
}
public function index(Request $request, string $id, SimulationFormDrafts $drafts)
public function index(Request $request, string $id, SimulationFormDrafts $drafts): JsonResponse
{
abort_unless(Str::isUuid($id), 404);
$drafts->find($id, $request);
$documents = DB::table('documents')->where('source_system', 'simulation-fiscal-offer')->where('external_id', $id)->whereNull('deleted_at')->get();
return response()->json(['data' => $documents->map(fn ($doc) => $this->metadata($id, $doc))])->header('Cache-Control', 'private, no-store');
}
public function download(Request $request, string $id, string $document, SimulationFormDrafts $drafts)
public function download(Request $request, string $id, string $document, SimulationFormDrafts $drafts): BinaryFileResponse
{
abort_unless(Str::isUuid($id) && Str::isUuid($document), 404);
$drafts->find($id, $request);
$doc = DB::table('documents')->where('id', $document)->where('source_system', 'simulation-fiscal-offer')->where('external_id', $id)->whereNull('deleted_at')->first();
abort_unless($doc, 404);
abort_unless($doc !== null, 404);
abort_unless($doc->status === 'active' && $doc->malware_scan_status === 'clean' && hash_equals(trim($doc->sha256), (string) $doc->malware_scanned_sha256), 409, 'Anexo conservado; descarga disponível após análise antimalware concluída.');
$root = realpath(Storage::disk('local')->path('simulation-offers/'.$id));
$path = realpath(Storage::disk('local')->path($doc->storage_key));
abort_unless($root && $path && str_starts_with(strtolower($path), strtolower($root.DIRECTORY_SEPARATOR)) && is_file($path), 404);
abort_unless(hash_equals(trim($doc->sha256), hash_file('sha256', $path)), 409, 'Conteúdo do anexo diverge do hash registado.');
$hash = hash_file('sha256', $path);
abort_unless(is_string($hash) && hash_equals(trim($doc->sha256), $hash), 409, 'Conteúdo do anexo diverge do hash registado.');
return response()->download($path, basename($doc->file_name), ['Cache-Control' => 'private, no-store', 'X-Content-Type-Options' => 'nosniff']);
}
private function metadata(string $id, object $doc): array
/** @return array{id: string, draftId: string, name: string, sha256: string, scan: string, url: string} */
private function metadata(string $id, stdClass $doc): array
{
return ['id' => $doc->id, 'draftId' => $id, 'name' => $doc->file_name, 'sha256' => trim($doc->sha256),
'scan' => $doc->malware_scan_status, 'url' => '/api/v1/simulation-drafts/'.$id.'/offer-documents/'.$doc->id];