app(\App\Services\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); 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(\App\Services\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(\App\Services\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; 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]); return ['id' => $id, 'version' => $version]; }); return $request->expectsJson() ? response()->json($result) : back()->with('success', 'Declaração de empresa guardada.'); } 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)]); } }