Files
Investidor-V2/app/Http/Controllers/PortalManagementController.php
T
nala 67dad461d9
tests / ci (push) Canceled after 0s
provincias
2026-09-29 14:14:06 +02:00

225 lines
12 KiB
PHP

<?php
namespace App\Http\Controllers;
use App\Models\InterestRequest;
use App\Models\InvestmentSubmission;
use App\Models\Opportunity;
use App\Models\Sector;
use App\Services\InvestmentGeography;
use App\Services\InvestmentWorkspace;
use App\Services\PortalAudit;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
use Inertia\Inertia;
use Inertia\Response;
class PortalManagementController extends Controller
{
private const STATES = ['received', 'in_review', 'contacted', 'closed'];
public function history(): Response
{
$events = DB::table('audit_events')
->leftJoin('users', 'users.id', '=', 'audit_events.actor_user_id')
->whereIn('audit_events.source_system', ['portal-management', 'investment-context', 'investment-submission', 'investment-workspace', 'investment-zones'])
->orderByDesc('audit_events.occurred_at')->orderByDesc('audit_events.id')
->select(['audit_events.id', 'audit_events.entity_id', 'entity_type', 'action', 'occurred_at', 'before_data', 'after_data', 'users.name as actor'])
->paginate(20)->through(function ($event) {
$before = json_decode($event->before_data ?? '{}', true) ?? [];
$after = json_decode($event->after_data ?? '{}', true) ?? [];
$values = $after['values'] ?? array_intersect_key($after, array_flip(['version', 'status', 'active']));
$event->before_data = ['values' => $before['values'] ?? []];
$event->after_data = ['record_id' => $after['record_id'] ?? $event->entity_id,
'changed_fields' => $after['changed_fields'] ?? array_keys($values), 'values' => $values];
return $event;
});
return Inertia::render('management/history', ['events' => $events]);
}
public function interests(Request $request): Response
{
$filters = $request->validate([
'q' => ['nullable', 'string', 'max:150'],
'status' => ['nullable', Rule::in(self::STATES)],
]);
$query = InterestRequest::query()->select([
'id', 'reference', 'full_name', 'email', 'sector_interest', 'status', 'created_at',
]);
if ($term = $filters['q'] ?? null) {
$query->where(fn ($q) => $q->whereLike('reference', "%{$term}%")
->orWhereLike('full_name', "%{$term}%")->orWhereLike('email', "%{$term}%"));
}
if ($status = $filters['status'] ?? null) {
$query->where('status', $status);
}
return Inertia::render('management/interests', [
'requests' => $query->orderByDesc('id')->paginate(20)->withQueryString(),
'filters' => $filters,
]);
}
public function interest(InterestRequest $interest): Response
{
return Inertia::render('management/interest', [
'interest' => $interest->only([
'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',
]),
]);
}
public function review(Request $request, InterestRequest $interest): RedirectResponse|JsonResponse
{
$validated = $request->validate(['status' => ['required', Rule::in(self::STATES)]]);
DB::transaction(function () use ($request, $interest, $validated) {
$locked = InterestRequest::whereKey($interest->id)->lockForUpdate()->firstOrFail();
$changed = $locked->status !== $validated['status'];
PortalAudit::save($locked, [
...$validated, 'reviewed_at' => now(), 'reviewed_by' => $request->user()->id,
], $request->user()->id);
$submission = InvestmentSubmission::where('interest_request_id', $interest->id)->first();
if ($changed && $submission) {
app(InvestmentWorkspace::class)->notify($submission->user_id, $submission, 'status', (string) Str::uuid(), ['status' => $validated['status']]);
}
});
return $request->expectsJson() ? response()->json(['reference' => $interest->reference, 'status' => $validated['status']]) : back()->with('success', 'Estado do pedido actualizado.');
}
public function catalog(Request $request, string $resource): Response
{
$model = $resource === 'sectors' ? Sector::class : Opportunity::class;
$filters = $request->validate([
'q' => ['nullable', 'string', 'max:150'],
'status' => ['nullable', Rule::in($resource === 'sectors' ? ['active', 'inactive'] : ['draft', 'published', 'archived'])],
'sector_id' => ['nullable', 'integer', Rule::exists('sectors', 'id')->whereNull('deleted_at')],
'page' => ['nullable', 'integer', 'min:1'],
]);
$query = $model::query();
if ($term = trim($filters['q'] ?? '')) {
$query->where(function ($query) use ($term, $resource) {
$query->whereLike($resource === 'sectors' ? 'name' : 'title', "%{$term}%")
->orWhereLike('slug', "%{$term}%");
if ($resource === 'opportunities') {
$query->orWhereLike('reference', "%{$term}%");
}
});
}
if ($status = $filters['status'] ?? null) {
if ($resource === 'sectors') {
$query->where('is_active', $status === 'active');
} elseif ($status === 'published') {
$query->where('status', 'published')->whereNotNull('published_at')->where('published_at', '<=', now());
} else {
$query->where('status', $status);
}
}
if ($resource === 'opportunities' && ($filters['sector_id'] ?? null)) {
$query->where('sector_id', $filters['sector_id']);
}
return Inertia::render('management/catalog', [
'resource' => $resource,
'records' => $query->orderByDesc('id')->paginate(20)->withQueryString(),
'filters' => $filters,
'sectors' => $resource === 'opportunities' ? Sector::orderBy('name')->get(['id', 'name']) : [],
]);
}
public function edit(string $resource, ?int $id = null): Response
{
$model = $resource === 'sectors' ? Sector::class : Opportunity::class;
$record = $id ? $model::findOrFail($id) : null;
return Inertia::render('management/editor', [
'resource' => $resource,
'record' => $record,
'territoryPath' => $record instanceof Opportunity ? app(InvestmentGeography::class)->path($record->primary_territory_id, true) : [],
'sectors' => Sector::orderBy('name')->get(['id', 'name']),
]);
}
public function save(Request $request, string $resource, ?int $id = null): RedirectResponse
{
$model = $resource === 'sectors' ? Sector::class : Opportunity::class;
$record = $id ? $model::findOrFail($id) : new $model;
$rules = $resource === 'sectors' ? [
'name' => ['required', 'string', 'max:150'],
'slug' => ['required', 'alpha_dash:ascii', 'max:180', Rule::unique('sectors')->ignore($record->id)],
'short_description' => ['nullable', 'string', 'max:255'],
'description' => ['nullable', 'string', 'max:20000'],
'sort_order' => ['required', 'integer', 'min:0', 'max:2147483647'],
'is_active' => ['required', 'boolean'],
] : [
'sector_id' => ['required', 'integer', Rule::exists('sectors', 'id')->whereNull('deleted_at')],
'reference' => ['required', 'string', 'max:80', Rule::unique('opportunities')->ignore($record->id)],
'title' => ['required', 'string', 'max:200'],
'slug' => ['required', 'alpha_dash:ascii', 'max:220', Rule::unique('opportunities')->ignore($record->id)],
'summary' => ['nullable', 'string', 'max:500'],
'description' => ['nullable', 'string', 'max:50000'],
'province' => ['nullable', 'string', 'max:100'],
'district' => ['nullable', 'string', 'max:100'],
'promoter_name' => ['nullable', 'string', 'max:200'],
'promoter_type' => ['nullable', 'string', 'max:100'],
'maturity_level' => ['nullable', 'integer', 'between:1,6'],
'estimated_investment_amount' => ['nullable', 'regex:/^\d{1,16}(\.\d{1,2})?$/'],
'estimated_investment_currency' => ['required', 'regex:/^[A-Z]{3}$/'],
'financing_modality' => ['nullable', 'string', 'max:120'],
'expected_impact' => ['nullable', 'string', 'max:10000'],
'status' => ['required', Rule::in(['draft', 'published', 'archived'])],
'is_featured' => ['required', 'boolean'],
];
if ($resource === 'opportunities') {
foreach (InvestmentGeography::FIELDS as $field => $type) {
$rules[$field] = ['nullable', 'uuid'];
}
$rules['territory_version'] = [$id && $request->hasAny(array_keys(InvestmentGeography::FIELDS)) ? 'required' : 'nullable', 'integer', 'min:0'];
}
$data = $request->validate($rules);
if ($resource === 'opportunities' && $data['status'] === 'published' && $record->exists
&& Schema::hasTable('education_investment_proposals')
&& DB::table('education_investment_proposals')->where('opportunity_id', $record->id)->exists()) {
throw ValidationException::withMessages([
'status' => 'Esta proposta educativa aguarda validação institucional da lacuna e da resposta formativa. A revisão interna não autoriza publicação.',
]);
}
if ($resource === 'opportunities') {
$labels = [1 => 'Ideia identificada', 'Pré-estudo disponível', 'Estudo de viabilidade em curso',
'Projecto estruturado', 'Pronto para investimento', 'Em negociação ou implementação'];
$data['maturity_label'] = $labels[$data['maturity_level'] ?? 0] ?? null;
$data['published_at'] = $data['status'] === 'published' ? ($record->published_at ?? now()) : null;
}
DB::transaction(function () use ($record, $data, $request, $resource) {
if ($resource === 'opportunities' && $request->hasAny(array_keys(InvestmentGeography::FIELDS))) {
DB::select('SELECT pg_advisory_xact_lock(280926, 150000)');
if ($record->exists) {
$current = Opportunity::whereKey($record->id)->lockForUpdate()->firstOrFail();
abort_unless((int) $current->territory_version === (int) $data['territory_version'], 409, 'Localização alterada. Recarregue a ficha.');
}
$path = app(InvestmentGeography::class)->selection($data, true);
$data = array_diff_key($data, InvestmentGeography::FIELDS);
$data['primary_territory_id'] = $path === [] ? null : $path[array_key_last($path)]->id;
$data['territory_version'] = (int) ($data['territory_version'] ?? 0) + 1;
$data['province'] = $path[0]->name ?? null;
$data['district'] = $path[1]->name ?? null;
} else {
unset($data['territory_version']);
}
PortalAudit::save($record, $data, $request->user()->id);
});
return redirect('/management/'.$resource)->with('success', 'Registo guardado.');
}
}