oportunidades 7
tests / ci (push) Waiting to run

This commit is contained in:
2026-10-13 11:40:29 +02:00
parent 20971d523f
commit 287e991486
2902 changed files with 1085456 additions and 25957 deletions
@@ -15,6 +15,7 @@ use App\Services\InvestmentZones;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
@@ -51,6 +52,9 @@ class InvestmentSubmissionController extends Controller
abort_unless($company !== null, 404);
$companyData = json_decode(Crypt::decryptString($company->payload), true, 512, JSON_THROW_ON_ERROR);
$payload = [...$payload, 'organisation' => $companyData['name'], 'representative_role' => $companyData['role'], 'incorporation_country_code' => $companyData['incorporation_country_code'] ?? null];
if (isset($companyData['organization_country_snapshot'])) {
$payload['organization_country_snapshot'] = $companyData['organization_country_snapshot'];
}
}
unset($data['company_id']);
if (! empty($data['opportunity_id'])) {
@@ -145,7 +149,8 @@ class InvestmentSubmissionController extends Controller
{
$required = $submitting ? 'required' : 'nullable';
$data = $request->validate(['version' => ['required', 'integer', 'min:1'],
'payload' => ['required', 'array:full_name,email,phone,country,residence_country_code,incorporation_country_code,declared_origin_country_code,organisation,representative_role,experience,investment_capacity,profile_id,sector,objective,participation_id,partnership_id,estimated_amount,currency,horizon,location,cluster_ids,corridor_ids,project_ids,contact_consent,other_details'],
'payload' => ['required', 'array:full_name,email,phone,country,residence_country_code,incorporation_country_code,organization_country_snapshot,declared_origin_country_code,organisation,representative_role,experience,investment_capacity,profile_id,sector,objective,participation_id,partnership_id,estimated_amount,currency,horizon,location,cluster_ids,corridor_ids,project_ids,contact_consent,other_details'],
'payload.organization_country_snapshot' => ['sometimes', 'array', 'max:10'],
'payload.residence_country_code' => ['nullable', 'string', app(CountryCatalogue::class)->rule()],
'payload.incorporation_country_code' => ['nullable', 'string', app(CountryCatalogue::class)->rule()],
'payload.declared_origin_country_code' => ['nullable', 'string', app(CountryCatalogue::class)->rule()],
@@ -217,7 +222,7 @@ class InvestmentSubmissionController extends Controller
$version = DB::transaction(function () use ($request, $id, $data) {
$row = $this->owned($request, $id, true, true);
abort_unless($row->status === 'draft' && $row->version === (int) $data['version'], 409, 'Rascunho alterado. Recarregue antes de guardar.');
$row->update(['payload' => $data['payload'], 'version' => $row->version + 1]);
$row->update(['payload' => $this->preserveCountrySource($row, $data['payload']), 'version' => $row->version + 1]);
InvestmentSpecificNeed::where('submission_id', $id)->delete();
foreach ($data['needs'] ?? [] as $need) {
InvestmentSpecificNeed::create([...$need, 'submission_id' => $id]);
@@ -230,6 +235,23 @@ class InvestmentSubmissionController extends Controller
return $request->expectsJson() ? response()->json(['version' => $version]) : back()->with('success', 'Rascunho guardado.');
}
/** @param array<string, mixed> $payload
* @return array<string, mixed> */
private function preserveCountrySource(InvestmentSubmission $row, array $payload): array
{
$source = $row->payload['organization_country_snapshot'] ?? null;
if (isset($payload['organization_country_snapshot']) && (! is_array($payload['organization_country_snapshot']) || ! is_array($source) ||
Arr::sortRecursive($payload['organization_country_snapshot']) !== Arr::sortRecursive($source))) {
throw ValidationException::withMessages(['payload.organization_country_snapshot' => 'A proveniência é recuperada no servidor; não pode ser substituída pelo formulário.']);
}
unset($payload['organization_country_snapshot']);
if ($source && ($row->payload['incorporation_country_code'] ?? null) === ($payload['incorporation_country_code'] ?? null)) {
$payload['organization_country_snapshot'] = $source;
}
return $payload;
}
public function submit(Request $request, string $id, InterestSubmission $intake): RedirectResponse|JsonResponse
{
// Replaying a completed submission returns only the owner's existing receipt.
@@ -248,7 +270,7 @@ class InvestmentSubmissionController extends Controller
if ($row->dossier_id) {
InvestmentPortfolioController::visible()->findOrFail($row->dossier_id);
}
$p = $data['payload'];
$p = $this->preserveCountrySource($row, $data['payload']);
$zoneOrigin = DB::table('investment_submission_zones')->where('submission_id', $id)->first(['zone_id', 'zone_version', 'intent']);
if ($zoneOrigin) {
$zone = app(InvestmentZones::class)->visible()->whereKey($zoneOrigin->zone_id)->lockForUpdate()->firstOrFail();
@@ -268,6 +290,7 @@ class InvestmentSubmissionController extends Controller
'declared_origin_country_code' => $p['declared_origin_country_code'] ?? null,
'residence_country_code' => $p['residence_country_code'] ?? null,
'incorporation_country_code' => $p['incorporation_country_code'] ?? null,
...(isset($p['organization_country_snapshot']) ? ['organization_country_snapshot' => $p['organization_country_snapshot']] : []),
...($zoneOrigin ? ['zone_origin' => (array) $zoneOrigin] : [])]]));
$row->update(['payload' => $p, 'version' => $row->version + 1, 'interest_request_id' => $interest->id, 'status' => 'submitted']);
InvestmentSpecificNeed::where('submission_id', $id)->delete();
@@ -6,6 +6,7 @@ use App\Models\InvestmentSubmission;
use App\Models\User;
use App\Services\CountryCatalogue;
use App\Services\InvestmentWorkspace;
use App\Services\OrganizationCountry;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
@@ -19,9 +20,10 @@ class InvestmentWorkspaceController extends Controller
{
public function profile(Request $request, InvestmentWorkspace $workspace): Response|JsonResponse
{
$data = ['countries' => app(CountryCatalogue::class)->all(), 'account' => $workspace->account($request->user()->id), 'companies' => DB::table('investment_company_declarations')
$data = ['organizationCountryAvailable' => app(OrganizationCountry::class)->available(), 'organizations' => app(OrganizationCountry::class)->represented($request->user()), 'countries' => app(CountryCatalogue::class)->all(), 'account' => $workspace->account($request->user()->id), 'companies' => DB::table('investment_company_declarations')
->where('user_id', $request->user()->id)->orderByDesc('created_at')->get(['id', 'version', 'payload'])->map(function ($company) {
$company->payload = json_decode(Crypt::decryptString($company->payload), true, 512, JSON_THROW_ON_ERROR);
$company->countrySource = $company->payload['organization_country_snapshot'] ?? null;
return $company;
})];
@@ -68,9 +70,15 @@ class InvestmentWorkspaceController extends Controller
abort_if(! $id && DB::table('investment_company_declarations')->where('user_id', $request->user()->id)->count() >= 20, 422, 'Máximo de vinte empresas declaradas.');
$id ??= (string) Str::uuid();
$version = (int) $data['version'] + 1;
$previous = $row ? json_decode(Crypt::decryptString($row->payload), true, 512, JSON_THROW_ON_ERROR) : [];
$payload = [...$previous, ...$data['payload']];
if (($previous['incorporation_country_code'] ?? null) !== ($payload['incorporation_country_code'] ?? null)) {
unset($payload['organization_country_snapshot']);
}
$encrypted = Crypt::encryptString(json_encode($payload, JSON_THROW_ON_ERROR));
DB::table('investment_company_declarations')->updateOrInsert(['id' => $id], ['user_id' => $request->user()->id, 'version' => $version,
'payload' => Crypt::encryptString(json_encode($data['payload'], JSON_THROW_ON_ERROR)), 'created_at' => $row->created_at ?? now(), 'updated_at' => now()]);
$this->audit($request, 'company_declared', 'investment_company_declarations', $id, ['version' => $version]);
'payload' => $encrypted, 'created_at' => $row->created_at ?? now(), 'updated_at' => now()]);
$this->audit($request, 'company_declared', 'investment_company_declarations', $id, ['version' => $version, 'before_payload' => $row?->payload, 'after_payload' => $encrypted]);
return ['id' => $id, 'version' => $version];
});
@@ -78,6 +86,36 @@ class InvestmentWorkspaceController extends Controller
return $request->expectsJson() ? response()->json($result) : back()->with('success', 'Declaração de empresa guardada.');
}
public function organizationCountry(Request $request, string $id, OrganizationCountry $service): RedirectResponse|JsonResponse
{
$result = $service->save($request->user(), $id, $request->all());
return $request->expectsJson() ? response()->json($result) : back()->with('success', 'País de constituição guardado no cadastro mestre; submissões anteriores preservadas.');
}
public function reuseOrganizationCountry(Request $request, string $id, OrganizationCountry $service): RedirectResponse|JsonResponse
{
$data = $request->validate(['version' => 'required|integer|min:1', 'organization_id' => 'required|uuid', 'organization_version' => 'required|integer|min:1']);
$result = DB::transaction(function () use ($request, $id, $data, $service) {
$row = DB::table('investment_company_declarations')->where('id', $id)->where('user_id', $request->user()->id)->lockForUpdate()->first();
abort_unless($row !== null, 404);
abort_unless((int) $row->version === (int) $data['version'], 409, 'Declaração alterada. Recarregue.');
$snapshot = $service->snapshot($request->user(), $data['organization_id']);
abort_unless((int) $snapshot['organization_version'] === (int) $data['organization_version'], 409, 'País mestre alterado. Recarregue.');
$payload = json_decode(Crypt::decryptString($row->payload), true, 512, JSON_THROW_ON_ERROR);
$payload['incorporation_country_code'] = $snapshot['country'];
$payload['organization_country_snapshot'] = $snapshot;
$encrypted = Crypt::encryptString(json_encode($payload, JSON_THROW_ON_ERROR));
DB::table('investment_company_declarations')->where('id', $id)->update(['payload' => $encrypted, 'version' => $row->version + 1, 'updated_at' => now()]);
$this->audit($request, 'organization_country_reused', 'investment_company_declarations', $id, ['version' => $row->version + 1,
'before_payload' => $row->payload, 'after_payload' => $encrypted, 'organization_id' => $data['organization_id'], 'organization_version' => $snapshot['organization_version']]);
return ['version' => $row->version + 1, 'country' => $snapshot['country']];
});
return $request->expectsJson() ? response()->json($result) : back()->with('success', 'País mestre reutilizado nesta declaração; manifestações anteriores não alteradas.');
}
public function notifications(Request $request): Response|JsonResponse
{
$query = DB::table('investment_notifications')->where('user_id', $request->user()->id);
@@ -25,8 +25,9 @@ class InvestorPathController extends Controller
{
$source = $request->input('content_resource_id');
if ($source !== null) {
abort_unless(filter_var($source, FILTER_VALIDATE_INT), 422);
app(LegacyContent::class)->articleForResource((int) $source);
$resourceId = filter_var($source, FILTER_VALIDATE_INT);
abort_unless(is_int($resourceId) && $resourceId !== 0, 422);
app(LegacyContent::class)->articleForResource($resourceId);
}
$id = DB::transaction(function () use ($path, $request, $source): string {
$id = $path->create($request->user(), $request->all());
@@ -309,6 +309,8 @@ class LandAssistanceController extends Controller
'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),
]);
}
@@ -9,6 +9,7 @@ use App\Services\OpportunityImageProcessor;
use App\Services\OpportunityManagement;
use App\Services\OpportunityResourceInstallation;
use App\Services\OpportunityResources;
use App\Services\OrganizationCountryInstallation;
use App\Services\PortalProfileAccess;
use App\Services\VerifiedDocumentBinary;
use Illuminate\Http\JsonResponse;
@@ -16,6 +17,7 @@ use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\URL;
use Illuminate\Support\Str;
use Inertia\Inertia;
use Inertia\Response;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
@@ -38,6 +40,39 @@ class OpportunityResourceController extends Controller
return back()->with('success', 'Recursos instalados; nenhum ficheiro publicado ou oportunidade submetida.');
}
public function organizationCountryPreview(Request $request, OrganizationCountryInstallation $service): JsonResponse|Response
{
$plan = $service->preview($request->user());
$scope = $this->countryOperationScope($request, $plan);
$operation = $request->session()->get('portal.country_installation_operation');
if (! is_array($operation) || ($operation['scope'] ?? null) !== $scope) {
$operation = ['scope' => $scope, 'key' => (string) Str::uuid()];
$request->session()->put('portal.country_installation_operation', $operation);
}
$plan = [...$plan, 'operationKey' => $operation['key'], 'actionUrl' => '/management/opportunity-resources-install/organization-country', 'extension' => true];
return $request->expectsJson() ? response()->json($plan) : Inertia::render('management/opportunity-install', $plan);
}
public function organizationCountryApply(Request $request, OrganizationCountryInstallation $service): JsonResponse|RedirectResponse
{
// Preview authorizes the actor; the UUID remains stable until this scope changes or logout.
$plan = $service->preview($request->user());
$operation = $request->session()->get('portal.country_installation_operation');
$data = $request->validate(['idempotency_key' => 'required|uuid']);
abort_unless(is_array($operation) && ($operation['scope'] ?? null) === $this->countryOperationScope($request, $plan)
&& hash_equals($operation['key'], $data['idempotency_key']), 409, 'A operação não corresponde a esta prévia. Atualize a página antes de tentar novamente.');
$result = $service->apply($request->user(), $request->all());
return $request->expectsJson() ? response()->json($result) : back()->with('success', 'País mestre instalado; nenhuma submissão anterior alterada.');
}
/** @param array<string, mixed> $plan */
private function countryOperationScope(Request $request, array $plan): string
{
return hash('sha256', json_encode([$request->user()->getAuthIdentifier(), $plan['target'], $plan['migration'], $plan['code_hash']], JSON_THROW_ON_ERROR));
}
public function index(int $id, OpportunityResources $service): Response
{
$opportunity = Opportunity::withTrashed()->findOrFail($id);
@@ -14,6 +14,7 @@ use App\Services\InvestmentGeography;
use App\Services\InvestmentWorkspace;
use App\Services\OpportunityManagement;
use App\Services\PortalAudit;
use App\Services\PortalProfileAccess;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
@@ -126,7 +127,7 @@ class PortalManagementController extends Controller
'id', 'reference', 'full_name', 'email', 'phone', 'investor_type', 'country',
'sector_interest', 'opportunity_reference', 'investment_range', 'message',
'privacy_consent', 'consented_at', 'status', 'created_at', 'reviewed_at', 'reviewed_by', 'metadata',
'existing_business_id', 'modality', 'assigned_to', 'next_action', 'next_action_due_at',
'existing_business_id', 'modality', 'assigned_to', 'next_action', 'next_action_due_at', 'source_page',
]),
'history' => $history,
'consents' => $consents,
@@ -231,7 +232,7 @@ class PortalManagementController extends Controller
$query = $model::query();
if ($resource === 'opportunities') {
if (Schema::hasColumn('opportunities', 'owner_user_id')) {
$query = app(\App\Services\PortalProfileAccess::class)->scope(Opportunity::query(), $request->user());
$query = app(PortalProfileAccess::class)->scope(Opportunity::query(), $request->user());
}
$query->with('primaryTerritory:id,name,location_type,source_system,source_code');
if (isset($filters['geography_scope'])) {
@@ -3,8 +3,11 @@
namespace App\Http\Controllers;
use App\Models\Opportunity;
use App\Services\OpportunityCanonicalContract;
use App\Services\OpportunityContacts;
use App\Services\OpportunityManagement;
use App\Services\OpportunityProfileWorkflow;
use App\Services\PortalInternalDesignation;
use App\Services\PortalProfileAccess;
use App\Services\PortalProfileAdministration;
use App\Services\PortalSuperusers;
@@ -47,6 +50,8 @@ class PortalProfileController extends Controller
return Inertia::render('profiles/administration', ['requests' => DB::table('portal_profile_requests')->latest()->get(),
'assignments' => DB::table('portal_role_assignments')->get(), 'pair' => DB::table('portal_superuser_pair')->first(),
'operatorOrganizations' => app(PortalInternalDesignation::class)->organizations(),
'internalTypes' => OpportunityCanonicalContract::BASE_CATEGORIES,
'actorId' => $request->user()->id, 'types' => OpportunityManagement::CATEGORIES,
'organizations' => DB::table('organizations')->whereNull('deleted_at')->get(['id', 'name']),
'securityRequests' => DB::table('portal_security_approvals')->latest()->limit(30)->get(),
@@ -83,6 +88,11 @@ class PortalProfileController extends Controller
return Inertia::render('profiles/audit', ['records' => $records, 'filters' => $filters]);
}
public function registerOperatorOrganization(Request $request, PortalInternalDesignation $service): JsonResponse
{
return response()->json($service->registerOrganization($request->user(), $request->all()), 201);
}
public function grant(Request $request, PortalProfileAdministration $service): JsonResponse
{
return response()->json(['id' => $service->grant($request->user(), $request->all())], 201);
@@ -131,6 +141,11 @@ class PortalProfileController extends Controller
return response()->json(['id' => $id, 'status' => app(PortalProfileAccess::class)->state($record), 'publication_status' => $record->status, 'etag' => app(OpportunityManagement::class)->etag($record), 'message' => 'Ação interna registada; não representa decisão externa.']);
}
public function contact(Request $request, int $id, OpportunityContacts $service): JsonResponse
{
return response()->json($service->save($request->user(), $id, $request->all()));
}
public function replacement(Request $request, PortalSuperusers $service): JsonResponse
{
$data = $request->validate(['old_user_id' => 'required|integer', 'new_user_id' => 'required|integer', 'reason' => 'required|string|min:20|max:1000']);
@@ -26,4 +26,15 @@ class PortalProfileInstallationController extends Controller
{
return response()->json($installation->bootstrap($request->user(), $request->all()));
}
public function bootstrapPreview(Request $request, string $id, PortalProfileInstallation $installation): JsonResponse|Response
{
$plan = $installation->bootstrapPreview($request->user(), $id);
return $request->expectsJson() ? response()->json($plan) : Inertia::render('profiles/bootstrap-confirmation', $plan);
}
public function confirmBootstrap(Request $request, string $id, PortalProfileInstallation $installation): JsonResponse
{
return response()->json($installation->confirmBootstrap($request->user(), $id, $request->all()));
}
}
@@ -236,13 +236,22 @@ class PortalServicesController extends Controller
abort_unless(DB::table('portal_partnerships')->where('id', $data['partnership_id'])->where('status', 'published')->exists(), 422);
}
$data['portal_user_id'] = $request->user()?->id;
// Preserve the actual entry form, not only the shared POST endpoint. Never accept an external or administrative referrer as the entry page.
$entry = parse_url($request->headers->get('referer', ''));
$sourcePage = '/apoio';
if (is_array($entry) && strtolower($entry['scheme'] ?? '') === $request->getScheme()
&& strtolower($entry['host'] ?? '') === strtolower($request->getHost())
&& (int) ($entry['port'] ?? (($entry['scheme'] ?? '') === 'https' ? 443 : 80)) === $request->getPort()
&& in_array($entry['path'] ?? '', ['/apoio', '/formularios/investidor', '/formularios/empresa', '/parcerias', '/parcerias/painel'], true)) {
$sourcePage = $entry['path'];
}
try {
$interest = $submission->receive($data, $data['submission_key'], fn () => InterestRequest::create([
'reference' => 'AP-'.now()->year.'-'.Str::ulid(), 'full_name' => $data['fullName'], 'email' => $data['email'],
'phone' => $data['phone'] ?? null, 'country' => $data['country'],
'investor_type' => $data['kind'] === 'company' ? 'empresa' : 'individual',
'sector_interest' => $data['sectorInterest'], 'message' => $data['message'],
'privacy_consent' => true, 'consented_at' => now(), 'status' => 'received', 'source_page' => '/apoio',
'privacy_consent' => true, 'consented_at' => now(), 'status' => 'received', 'source_page' => $sourcePage,
'metadata' => ['kind' => $data['kind'], 'company' => $data['company'] ?? null,
'portal_user_id' => $data['portal_user_id'], 'partnership_id' => $data['partnership_id'] ?? null,
'declared_origin_country_code' => $data['declared_origin_country_code'] ?? null],
@@ -637,7 +646,7 @@ class PortalServicesController extends Controller
'document_id' => ['required', 'uuid'], 'title' => ['required', 'string', 'max:200'],
'description' => ['nullable', 'string', 'max:4000'], 'category' => ['required', 'string', 'max:80'],
// QA legacy originals may have no theme (e.g. a shared image). Do not invent a sector to disclose an authorized original.
'topic' => [app(\App\Services\LegacyContent::class)->available() ? 'nullable' : 'required', 'string', 'max:80'],
'topic' => [app(LegacyContent::class)->available() ? 'nullable' : 'required', 'string', 'max:80'],
'document_type' => ['required', 'in:legislation,procedure,official_form,digital_adaptation,guidance_template,guide,study,reference_data,unknown'],
'issuer' => ['nullable', 'string', 'max:180'], 'document_number' => ['nullable', 'string', 'max:120'],
'document_date' => ['nullable', 'date_format:Y-m-d'], 'source_url' => ['nullable', 'url:https', 'max:2000'],
@@ -3,12 +3,14 @@
namespace App\Http\Controllers;
use App\Services\TerritoryGeometryImport;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
class TerritoryGeometryImportController extends Controller
{
public function preview(Request $request, TerritoryGeometryImport $service)
public function preview(Request $request, TerritoryGeometryImport $service): JsonResponse|Response
{
$data = $request->validate(['level' => 'nullable|integer|in:1,2,3']);
$plan = $service->preview($request->user(), (int) ($data['level'] ?? 1));
@@ -16,12 +18,12 @@ class TerritoryGeometryImportController extends Controller
return $request->expectsJson() ? response()->json($plan) : Inertia::render('management/territory-geometry-import', ['plan' => $plan]);
}
public function apply(Request $request, TerritoryGeometryImport $service)
public function apply(Request $request, TerritoryGeometryImport $service): JsonResponse
{
return response()->json($service->apply($request->user(), $request->all()));
}
public function reverse(Request $request, string $batch, TerritoryGeometryImport $service)
public function reverse(Request $request, string $batch, TerritoryGeometryImport $service): JsonResponse
{
return response()->json($service->reverse($request->user(), $batch, $request->all()));
}