Opportunity::findOrFail($id), 'context' => $catalogue->context($id, true), 'references' => $catalogue->references(true)]); } public function save(Request $request, int $id): RedirectResponse|JsonResponse { $rules = ['version' => ['required', 'integer', 'min:0'], 'is_education' => ['required', 'boolean'], 'business_model' => ['nullable', 'string', 'max:5000'], 'requirements' => ['nullable', 'string', 'max:10000'], 'risks' => ['nullable', 'string', 'max:5000'], 'target_date' => ['nullable', 'date_format:Y-m-d'], 'source_reference' => ['required', 'string', 'min:10', 'max:2000'], 'facets' => ['array', 'max:100'], 'facets.*' => ['integer', 'distinct', Rule::exists('investment_reference_options', 'id')->where('active', true)]]; foreach (InvestmentCatalogue::RELATIONS as $relation => [$foreign, $table]) { $rules[$relation] = ['array', 'max:100']; $rules[$relation.'.*'] = ['uuid', 'distinct', Rule::exists($table, 'id')->whereNull('deleted_at')]; } $data = $request->validate($rules); $version = DB::transaction(function () use ($request, $id, $data) { $opportunity = Opportunity::whereKey($id)->lockForUpdate()->firstOrFail(); $profile = DB::table('opportunity_profiles')->where('opportunity_id', $id)->first(); abort_unless((int) ($profile->version ?? 0) === (int) $data['version'], 409, 'Contexto alterado. Recarregue.'); $version = $data['version'] + 1; DB::table('opportunity_profiles')->updateOrInsert(['opportunity_id' => $id], [ ...array_intersect_key($data, array_flip(['is_education', 'business_model', 'requirements', 'risks', 'target_date', 'source_reference'])), 'version' => $version, 'created_at' => $profile->created_at ?? now(), 'updated_at' => now()]); foreach (InvestmentCatalogue::RELATIONS as $relation => [$foreign, $table]) { DB::table('opportunity_'.$relation)->where('opportunity_id', $id)->delete(); foreach ($data[$relation] ?? [] as $value) { DB::table('opportunity_'.$relation)->insert(['opportunity_id' => $id, $foreign => $value]); } } DB::table('opportunity_facets')->where('opportunity_id', $id)->delete(); foreach ($data['facets'] ?? [] as $value) { DB::table('opportunity_facets')->insert(['opportunity_id' => $id, 'option_id' => $value]); } // Editing public context produces a draft for explicit approval, never silent publication. $opportunity->update(['status' => 'draft', 'published_at' => null]); $this->audit($request, 'opportunity_context', $id, 'context_saved', ['version' => $version]); return $version; }); return $request->expectsJson() ? response()->json(['version' => $version]) : back()->with('success', 'Contexto guardado. A ficha está em rascunho para aprovação.'); } public function references(): Response { return Inertia::render('investment/references', ['options' => DB::table('investment_reference_options')->orderBy('group')->orderBy('label')->get()]); } public function saveReference(Request $request, ?int $id = null): RedirectResponse|JsonResponse { $data = $request->validate(['group' => ['required', 'string', Rule::exists('investment_reference_options', 'group')], 'code' => ['required', 'alpha_dash:ascii', 'max:80'], 'label' => ['required', 'string', 'max:200'], 'active' => ['required', 'boolean'], 'requires_description' => ['required', 'boolean'], 'sort_order' => ['required', 'integer', 'between:0,10000']]); $savedId = DB::transaction(function () use ($request, $id, $data) { DB::select('select pg_advisory_xact_lock(270927, hashtext(?))', [$data['group']]); if ($id) { $current = DB::table('investment_reference_options')->where('id', $id)->lockForUpdate()->first(); abort_unless($current !== null, 404); abort_unless($current->group === $data['group'] && $current->code === $data['code'], 422, 'Grupo e código são estáveis para preservar referências.'); abort_if($current->code === 'other' && (! $data['active'] || ! $data['requires_description']), 422, 'Preserve a opção Outro — especificar.'); DB::table('investment_reference_options')->where('id', $id)->update([...$data, 'updated_at' => now()]); $savedId = $id; } else { abort_if(DB::table('investment_reference_options')->where('group', $data['group'])->where('code', $data['code'])->exists(), 422, 'Código já existente neste grupo.'); $savedId = DB::table('investment_reference_options')->insertGetId([...$data, 'created_at' => now(), 'updated_at' => now()]); } $this->audit($request, 'investment_reference_options', $savedId, 'reference_saved', ['active' => $data['active']]); return $savedId; }); return $request->expectsJson() ? response()->json(['id' => $savedId]) : back()->with('success', 'Opção guardada. Os registos anteriores foram preservados.'); } /** @param array $values */ private function audit(Request $request, string $entity, int $id, string $action, array $values): void { DB::table('audit_events')->insert(['source_system' => 'investment-context', 'entity_type' => $entity, 'actor_user_id' => $request->user()->id, 'action' => $action, 'after_data' => json_encode(['record_id' => $id, ...$values], JSON_THROW_ON_ERROR)]); } }