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; })]; return $request->expectsJson() ? response()->json($data) : Inertia::render('investment/profile', $data); } public function saveProfile(Request $request): RedirectResponse|JsonResponse { $data = $request->validate(['version' => ['required', 'integer', 'min:0'], 'payload' => ['required', 'array:phone,country,residence_country_code,sector,experience,investment_capacity'], 'payload.residence_country_code' => ['nullable', 'string', app(CountryCatalogue::class)->rule()], 'payload.phone' => ['nullable', 'string', 'max:30'], 'payload.country' => ['nullable', 'string', 'max:100'], 'payload.sector' => ['nullable', 'string', 'max:150'], 'payload.experience' => ['nullable', 'string', 'max:2000'], 'payload.investment_capacity' => ['nullable', 'string', 'max:1000']]); $version = DB::transaction(function () use ($request, $data) { DB::table('users')->where('id', $request->user()->id)->lockForUpdate()->firstOrFail(); $row = DB::table('investment_accounts')->where('user_id', $request->user()->id)->first(); abort_unless((int) ($row->version ?? 0) === (int) $data['version'], 409, 'Perfil alterado. Recarregue.'); $version = (int) $data['version'] + 1; DB::table('investment_accounts')->updateOrInsert(['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, 'profile_saved', 'investment_accounts', null, ['version' => $version]); return $version; }); return $request->expectsJson() ? response()->json(['version' => $version]) : back()->with('success', 'Perfil de investimento guardado.'); } public function company(Request $request, ?string $id = null): RedirectResponse|JsonResponse { $data = $request->validate(['version' => ['required', 'integer', 'min:0'], 'payload' => ['required', 'array:name,country,incorporation_country_code,registration,sector,role,description'], 'payload.incorporation_country_code' => ['nullable', 'string', app(CountryCatalogue::class)->rule()], 'payload.name' => ['required', 'string', 'min:2', 'max:200'], 'payload.country' => ['required_without:payload.incorporation_country_code', 'nullable', 'string', 'max:100'], 'payload.registration' => ['nullable', 'string', 'max:100'], 'payload.sector' => ['nullable', 'string', 'max:150'], 'payload.role' => ['required', 'string', 'max:150'], 'payload.description' => ['nullable', 'string', 'max:2000']]); $result = DB::transaction(function () use ($request, $id, $data) { DB::table('users')->where('id', $request->user()->id)->lockForUpdate()->firstOrFail(); $row = $id ? DB::table('investment_company_declarations')->where('id', $id)->where('user_id', $request->user()->id)->first() : null; abort_if($id && ! $row, 404); abort_unless((int) ($row->version ?? 0) === (int) $data['version'], 409, 'Declaração alterada. Recarregue.'); 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' => $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]; }); 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); if (! $request->user()->can('manage-portal')) { $query->whereIn('submission_id', InvestmentSubmission::where('user_id', $request->user()->id)->select('id')); } $data = ['unread' => (clone $query)->whereNull('read_at')->count(), 'notifications' => $query->orderByDesc('created_at')->paginate(20)->through(function ($row) { $row->payload = json_decode(Crypt::decryptString($row->payload), true, 512, JSON_THROW_ON_ERROR); return $row; })]; return $request->expectsJson() ? response()->json($data) : Inertia::render('investment/notifications', $data); } public function readNotification(Request $request, string $id): RedirectResponse|JsonResponse { $row = DB::table('investment_notifications')->where('id', $id)->where('user_id', $request->user()->id)->first(); abort_unless($row !== null, 404); $this->submission($request, $row->submission_id); DB::table('investment_notifications')->where('id', $id)->whereNull('read_at')->update(['read_at' => now()]); return $request->expectsJson() ? response()->json(['read' => true]) : back(); } public function message(Request $request, string $id, InvestmentWorkspace $workspace): RedirectResponse|JsonResponse { $data = $request->validate(['client_key' => ['required', 'uuid'], 'body' => ['required', 'string', 'min:3', 'max:5000']]); $messageId = DB::transaction(function () use ($request, $id, $data, $workspace) { $submission = $this->submission($request, $id, true); abort_unless($submission->status === 'submitted', 409, 'Submeta a manifestação antes de enviar mensagens.'); $key = ['submission_id' => $id, 'actor_id' => $request->user()->id, 'client_key' => $data['client_key']]; $existing = DB::table('investment_messages')->where($key)->first(); if ($existing) { abort_unless(hash_equals(Crypt::decryptString($existing->body), $data['body']), 409, 'Chave já usada para outra mensagem.'); return $existing->id; } $isOwner = (int) $submission->user_id === $request->user()->id; $messageId = (string) Str::uuid(); DB::table('investment_messages')->insert([...$key, 'id' => $messageId, 'sender_role' => $isOwner ? 'investor' : 'support', 'body' => Crypt::encryptString($data['body']), 'created_at' => now()]); $recipients = $isOwner ? User::whereIn(DB::raw('lower(email)'), config('portal.managers', []))->whereNotNull('email_verified_at')->pluck('id')->all() : [$submission->user_id]; foreach ($recipients as $recipient) { if ((int) $recipient !== $request->user()->id) { $workspace->notify((int) $recipient, $submission, 'message', $messageId); } } $this->audit($request, 'message_sent', 'investment_messages', $messageId); return $messageId; }); return $request->expectsJson() ? response()->json(['id' => $messageId]) : back()->with('success', 'Mensagem registada na conversa.'); } public function revision(Request $request, string $id, int $version): JsonResponse { $this->submission($request, $id); $revision = DB::table('investment_submission_revisions')->where('submission_id', $id)->where('version', $version)->first(); abort_unless($revision !== null, 404); return response()->json(['version' => $version, 'snapshot' => json_decode(Crypt::decryptString($revision->snapshot), true, 512, JSON_THROW_ON_ERROR)]); } private function submission(Request $request, string $id, bool $lock = false): InvestmentSubmission { $query = InvestmentSubmission::whereKey($id); if (! $request->user()->can('manage-portal')) { $query->where('user_id', $request->user()->id); } return ($lock ? $query->lockForUpdate() : $query)->firstOrFail(); } /** @param array $metadata */ private function audit(Request $request, string $action, string $entity, ?string $id, array $metadata = []): void { DB::table('audit_events')->insert(['source_system' => 'investment-workspace', 'actor_user_id' => $request->user()->id, 'entity_type' => $entity, 'entity_id' => $id, 'action' => $action, 'after_data' => json_encode($metadata, JSON_THROW_ON_ERROR)]); } }