user()->id)->latest()->paginate(20)->through(function ($row) { $interest = $row->interest_request_id ? InterestRequest::find($row->interest_request_id) : null; return ['id' => $row->id, 'status' => $row->status, 'version' => $row->version, 'created_at' => $row->created_at, 'reference' => $interest?->reference, 'request_status' => $interest?->status]; }); $data = ['submissions' => $submissions, 'favourites' => Opportunity::published()->whereIn('id', DB::table('opportunity_favourites')->where('user_id', $request->user()->id)->select('opportunity_id'))->get(['id', 'title', 'slug'])]; return $request->expectsJson() ? response()->json($data) : Inertia::render('investment/workspace', $data); } public function create(Request $request, InvestmentCatalogue $catalogue): RedirectResponse|JsonResponse { $data = $request->validate(['opportunity_id' => ['nullable', 'integer'], 'dossier_id' => ['nullable', 'uuid'], 'company_id' => ['nullable', 'uuid'], 'zone_id' => ['nullable', 'uuid', 'prohibits:dossier_id,company_id'], 'intent' => ['nullable', 'required_with:zone_id', 'in:interest,dossier,meeting']]); abort_if(! empty($data['opportunity_id']) && ! empty($data['dossier_id']), 422); $payload = [...app(InvestmentWorkspace::class)->account($request->user()->id)['payload'], 'full_name' => $request->user()->name, 'email' => $request->user()->email]; if (! empty($data['company_id'])) { $company = DB::table('investment_company_declarations')->where('id', $data['company_id'])->where('user_id', $request->user()->id)->first(); 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'])) { $opportunity = Opportunity::published()->with('sector')->where('id', $data['opportunity_id'])->firstOrFail(); $payload['sector'] = $opportunity->sector?->name; $payload['location'] = implode(', ', array_filter([$opportunity->district, $opportunity->province])); $context = $catalogue->context($opportunity->id); foreach (['clusters' => 'cluster_ids', 'corridors' => 'corridor_ids', 'projects' => 'project_ids'] as $relation => $field) { $payload[$field] = $context[$relation]->pluck('id')->all(); } } if (! empty($data['dossier_id'])) { $dossier = InvestmentPortfolioController::visible()->where('id', $data['dossier_id'])->firstOrFail(); foreach (['cluster_id' => 'cluster_ids', 'corridor_id' => 'corridor_ids', 'project_id' => 'project_ids'] as $foreign => $field) { if ($dossier->$foreign) { $payload[$field] = [$dossier->$foreign]; } } } $zoneId = $data['zone_id'] ?? null; $intent = $data['intent'] ?? 'interest'; unset($data['zone_id'], $data['intent']); $submission = DB::transaction(function () use ($request, $data, $payload, $zoneId, $intent) { $zone = null; if ($zoneId) { $zones = app(InvestmentZones::class); $zone = $zones->visible()->whereKey($zoneId)->lockForUpdate()->firstOrFail(); if (! empty($data['opportunity_id'])) { abort_unless($zones->links()->join('investment_item_opportunities as m', 'm.item_id', '=', 'zl.item_id') ->where('zl.zone_id', $zoneId)->where('m.opportunity_id', $data['opportunity_id'])->exists(), 422, 'O investimento não tem associação pública à zona seleccionada.'); } // Serializes draft creation for this investor without a global lock. DB::table('users')->where('id', $request->user()->id)->lockForUpdate()->first(); $draft = InvestmentSubmission::where('user_id', $request->user()->id)->where('status', 'draft') ->where('opportunity_id', $data['opportunity_id'] ?? null) ->whereIn('id', DB::table('investment_submission_zones')->where('zone_id', $zoneId)->where('zone_version', $zone->version_no)->where('intent', $intent)->select('submission_id'))->first(); if ($draft) { return $draft; } } $row = InvestmentSubmission::create([...$data, 'user_id' => $request->user()->id, 'payload' => $payload, 'status' => 'draft', 'version' => 1]); if ($zone) { DB::table('investment_submission_zones')->insert(['submission_id' => $row->id, 'zone_id' => $zone->id, 'zone_version' => $zone->version_no, 'intent' => $intent]); } $this->event($row, $request, 'draft_created'); return $row; }); return $request->expectsJson() ? response()->json(['id' => $submission->id, 'version' => $submission->version], 201) : redirect('/investidor/manifestacoes/'.$submission->id); } public function show(Request $request, string $id, InvestmentCatalogue $catalogue): Response|JsonResponse { $row = $this->owned($request, $id); $interest = $row->interest_request_id ? InterestRequest::find($row->interest_request_id) : null; $target = $row->opportunity_id ? Opportunity::published()->find($row->opportunity_id) : null; $dossier = $row->dossier_id ? InvestmentPortfolioController::visible()->find($row->dossier_id) : null; $zoneOrigin = DB::table('investment_submission_zones')->where('submission_id', $id)->first(['zone_id', 'zone_version', 'intent']); $data = ['countries' => app(CountryCatalogue::class)->all(), 'submission' => $row->only(['id', 'payload', 'version', 'status', 'opportunity_id', 'dossier_id']), 'zone_origin' => $zoneOrigin, 'target' => ($target ? $target->title : null) ?? ($dossier ? $dossier->title : null), 'references' => [ ...$catalogue->references(), 'options' => DB::table('investment_reference_options')->orderBy('label')->get()], 'needs' => InvestmentSpecificNeed::where('submission_id', $id)->get(), 'attachments' => DB::table('investment_submission_attachments as a')->join('documents as d', 'd.id', '=', 'a.document_id') ->where('a.submission_id', $id)->whereNull('d.deleted_at')->get(['a.id', 'd.file_name']), 'events' => DB::table('investment_submission_events')->where('submission_id', $id)->orderBy('id')->get(['action', 'version', 'created_at']), 'revisions' => DB::table('investment_submission_revisions')->where('submission_id', $id)->orderByDesc('version')->get(['version', 'created_at']), 'messages' => DB::table('investment_messages')->where('submission_id', $id)->orderBy('created_at')->get(['id', 'sender_role', 'body', 'created_at'])->map(function ($message) { $message->body = Crypt::decryptString($message->body); return $message; }), 'receipt' => $interest?->only(['reference', 'status']), 'management' => (int) $row->user_id !== $request->user()->id]; return $request->expectsJson() ? response()->json($data) : Inertia::render('investment/submission', $data); } private function owned(Request $request, string $id, bool $lock = false, bool $ownerOnly = false): InvestmentSubmission { $query = InvestmentSubmission::whereKey($id); if ($ownerOnly || ! $request->user()->can('manage-portal')) { $query->where('user_id', $request->user()->id); } return ($lock ? $query->lockForUpdate() : $query)->firstOrFail(); } /** @return array */ private function validated(Request $request, bool $submitting): array { $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,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()], 'payload.other_details' => ['nullable', 'string', 'max:5000'], 'payload.full_name' => [$required, 'string', ...($submitting ? ['min:3'] : []), 'max:150'], 'payload.email' => [$required, $submitting ? 'email:rfc' : 'string', 'max:150'], 'payload.phone' => ['nullable', 'string', 'max:30'], 'payload.country' => [$submitting ? 'required_without:payload.declared_origin_country_code' : 'nullable', 'nullable', 'string', 'max:100'], 'payload.organisation' => ['nullable', 'string', 'max:200'], 'payload.representative_role' => ['nullable', 'string', 'max:150'], 'payload.experience' => ['nullable', 'string', 'max:2000'], 'payload.investment_capacity' => ['nullable', 'string', 'max:1000'], 'payload.profile_id' => [$required, 'integer'], 'payload.sector' => [$required, 'string', 'max:150'], 'payload.objective' => [$required, 'string', ...($submitting ? ['min:20'] : []), 'max:5000'], 'payload.participation_id' => [$required, 'integer'], 'payload.partnership_id' => ['nullable', 'integer'], 'payload.estimated_amount' => ['nullable', 'numeric', 'min:0', 'max:999999999999'], 'payload.currency' => ['nullable', 'required_with:payload.estimated_amount', 'regex:/^[A-Z]{3}$/'], 'payload.horizon' => [$required, 'string', 'max:150'], 'payload.location' => ['nullable', 'string', 'max:500'], 'payload.cluster_ids' => ['nullable', 'array', 'max:20'], 'payload.cluster_ids.*' => ['uuid', 'distinct'], 'payload.corridor_ids' => ['nullable', 'array', 'max:20'], 'payload.corridor_ids.*' => ['uuid', 'distinct'], 'payload.project_ids' => ['nullable', 'array', 'max:30'], 'payload.project_ids.*' => ['uuid', 'distinct'], 'payload.contact_consent' => $submitting ? ['accepted'] : ['nullable', 'boolean'], 'needs' => ['nullable', 'array', 'max:40'], 'needs.*' => ['array:category_id,option_id,unit_id,priority_id,quantity,deadline,details'], 'needs.*.category_id' => ['required', 'integer'], 'needs.*.option_id' => ['required', 'integer'], 'needs.*.unit_id' => ['nullable', 'integer'], 'needs.*.priority_id' => ['nullable', 'integer'], 'needs.*.quantity' => ['nullable', 'numeric', 'min:0', 'max:999999999999'], 'needs.*.deadline' => ['nullable', 'date_format:Y-m-d'], 'needs.*.details' => ['required', 'array:description,location,observations,qualification,specialty,experience,certifications,availability,institution,training_mode,duration,status'], 'needs.*.details.*' => ['nullable', 'string', 'max:2000'], 'needs.*.details.description' => ['required', 'string', 'min:3', 'max:2000']]); foreach (['profile_id' => 'investor_profile', 'participation_id' => 'participation', 'partnership_id' => 'partnership'] as $field => $group) { if (! empty($data['payload'][$field])) { $option = $this->option($data['payload'][$field], $group, 'payload.'.$field); if ($option->requires_description && mb_strlen(trim($data['payload']['other_details'] ?? '')) < 3) { throw ValidationException::withMessages(['payload.other_details' => 'Especifique as opções assinaladas como Outro.']); } } } foreach (['cluster_ids' => 'cluster_id', 'corridor_ids' => 'corridor_id', 'project_ids' => 'project_id'] as $field => $column) { $ids = $data['payload'][$field] ?? []; if ($ids && InvestmentPortfolioController::visible()->whereIn($column, $ids)->count() !== count($ids)) { throw ValidationException::withMessages(['payload.'.$field => 'Uma referência territorial já não está publicada.']); } } foreach ($data['needs'] ?? [] as $index => $need) { $category = $this->option($need['category_id'], 'need_category', 'needs.'.$index.'.category_id'); $group = match ($category->code) { 'labour_required', 'labour_available' => 'labour', 'infrastructure_existing', 'infrastructure_required' => 'infrastructure', 'education' => 'education', default => 'support', }; $this->option($need['option_id'], $group, 'needs.'.$index.'.option_id'); foreach (['unit_id' => 'unit', 'priority_id' => 'priority'] as $field => $expected) { if (! empty($need[$field])) { $this->option($need[$field], $expected, 'needs.'.$index.'.'.$field); } } } return $data; } private function option(int $id, string $group, string $field): \stdClass { $option = DB::table('investment_reference_options')->where('id', $id)->where('group', $group)->where('active', true)->first(); if (! $option) { throw ValidationException::withMessages([$field => 'Seleccione uma opção activa desta categoria.']); } return $option; } public function save(Request $request, string $id): RedirectResponse|JsonResponse { $data = $this->validated($request, false); $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' => $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]); } $this->event($row, $request, 'draft_saved'); return $row->version; }); return $request->expectsJson() ? response()->json(['version' => $version]) : back()->with('success', 'Rascunho guardado.'); } /** @param array $payload * @return array */ 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. $existing = $this->owned($request, $id, false, true); if ($existing->status === 'submitted') { $reference = InterestRequest::findOrFail($existing->interest_request_id)->reference; } else { $data = $this->validated($request, true); $reference = DB::transaction(function () use ($request, $id, $data, $intake) { $row = $this->owned($request, $id, true, true); if ($row->status === 'submitted') { return InterestRequest::findOrFail($row->interest_request_id)->reference; } abort_unless($row->version === (int) $data['version'], 409, 'Rascunho alterado. Recarregue.'); $opportunity = $row->opportunity_id ? Opportunity::published()->findOrFail($row->opportunity_id) : null; if ($row->dossier_id) { InvestmentPortfolioController::visible()->findOrFail($row->dossier_id); } $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(); abort_unless((int) $zone->version_no === (int) $zoneOrigin->zone_version, 409, 'A ficha da zona mudou. Consulte a versão actual antes de preparar novo pedido.'); if ($opportunity) { abort_unless(app(InvestmentZones::class)->links()->join('investment_item_opportunities as m', 'm.item_id', '=', 'zl.item_id') ->where('zl.zone_id', $zoneOrigin->zone_id)->where('m.opportunity_id', $opportunity->id)->exists(), 422, 'A associação à zona deixou de estar disponível.'); } } $interest = $intake->receive(['email' => $p['email'], 'fullName' => $p['full_name'], 'message' => $p['objective'], 'submission_id' => $id], $id, fn () => InterestRequest::create(['reference' => 'IR-'.now()->year.'-'.Str::ulid(), 'opportunity_id' => $opportunity?->id, 'opportunity_reference' => $opportunity?->reference, 'full_name' => $p['full_name'], 'email' => $p['email'], 'phone' => $p['phone'] ?? null, 'country' => $p['country'] ?? 'Não informado', 'investor_type' => empty($p['organisation']) ? 'individual' : 'empresa', 'sector_interest' => $p['sector'], 'message' => $p['objective'], 'privacy_consent' => true, 'consented_at' => now(), 'status' => 'received', 'source_page' => '/investidor/manifestacoes/'.$id, 'metadata' => ['submission_id' => $id, 'portal_user_id' => $request->user()->id, 'dossier_id' => $row->dossier_id, '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(); foreach ($data['needs'] ?? [] as $need) { InvestmentSpecificNeed::create([...$need, 'submission_id' => $id]); } $this->event($row, $request, 'submitted'); app(InvestmentWorkspace::class)->notify($row->user_id, $row, 'received', 'received:'.$row->id, ['reference' => $interest->reference]); return $interest->reference; }); } $status = InterestRequest::where('reference', $reference)->value('status'); return $request->expectsJson() ? response()->json(['reference' => $reference, 'status' => $status]) : back()->with('success', 'Manifestação recebida: '.$reference); } public function attach(Request $request, string $id): RedirectResponse|JsonResponse { $data = $request->validate(['version' => ['required', 'integer'], 'file' => ['required', 'file', 'mimetypes:application/pdf,text/plain', 'max:10240']]); $file = $request->file('file'); $documentId = (string) Str::uuid(); $key = null; try { $version = DB::transaction(function () use ($request, $id, $data, $file, $documentId, &$key) { $row = $this->owned($request, $id, true, true); abort_unless($row->status === 'draft' && $row->version === (int) $data['version'], 409); abort_if(DB::table('investment_submission_attachments')->where('submission_id', $id)->count() >= 10, 422, 'Máximo de dez anexos por pedido.'); $key = $file->storeAs('investment-submissions/'.$id, $documentId, 'local'); abort_unless($key !== false, 500); DB::table('documents')->insert(['id' => $documentId, 'source_system' => 'investor-submission', 'file_name' => basename($file->getClientOriginalName()), 'media_type' => $file->getMimeType(), 'storage_key' => $key, 'sha256' => hash_file('sha256', Storage::disk('local')->path($key)), 'size_bytes' => $file->getSize(), 'uploaded_by' => $request->user()->id, 'status' => 'quarantined', 'data_classification' => 'confidential']); DB::table('investment_submission_attachments')->insert(['id' => (string) Str::uuid(), 'submission_id' => $id, 'document_id' => $documentId, 'created_at' => now(), 'updated_at' => now()]); $row->increment('version'); $this->event($row, $request, 'attachment_added'); return $row->version; }); } catch (\Throwable $e) { if (is_string($key)) { Storage::disk('local')->delete($key); } throw $e; } ScanDocumentForMalware::dispatch($documentId)->afterCommit(); return $request->expectsJson() ? response()->json(['version' => $version]) : back()->with('success', 'Anexo privado guardado.'); } public function download(Request $request, string $id, string $attachment): BinaryFileResponse { $this->owned($request, $id); $doc = DB::table('investment_submission_attachments as a')->join('documents as d', 'd.id', '=', 'a.document_id') ->where('a.id', $attachment)->where('a.submission_id', $id)->where('d.status', 'active')->whereNull('d.deleted_at') ->where('d.malware_scan_status', 'clean')->whereColumn('d.malware_scanned_sha256', 'd.sha256') ->first(['d.file_name', 'd.storage_key', 'd.sha256', 'd.malware_scanned_sha256']); abort_unless($doc !== null, 404); $root = realpath(Storage::disk('local')->path('investment-submissions/'.$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); $hash = hash_file('sha256', $path); abort_unless(is_string($hash) && hash_equals($doc->sha256, $hash) && hash_equals($doc->sha256, (string) $doc->malware_scanned_sha256), 404); return response()->download($path, basename($doc->file_name), ['Cache-Control' => 'private, no-store', 'X-Content-Type-Options' => 'nosniff']); } private function event(InvestmentSubmission $row, Request $request, string $action): void { app(InvestmentWorkspace::class)->snapshot($row); DB::table('investment_submission_events')->insert(['submission_id' => $row->id, 'actor_id' => $request->user()->id, 'action' => $action, 'version' => $row->version, 'created_at' => now()]); DB::table('audit_events')->insert(['source_system' => 'investment-submission', 'entity_type' => 'investment_submissions', 'entity_id' => $row->id, 'actor_user_id' => $request->user()->id, 'action' => $action, 'after_data' => json_encode(['version' => $row->version, 'status' => $row->status], JSON_THROW_ON_ERROR)]); } }