242 lines
16 KiB
PHP
242 lines
16 KiB
PHP
<?php
|
|
|
|
namespace App\Http\Controllers;
|
|
|
|
use App\Models\InvestmentSubmission;
|
|
use App\Models\Opportunity;
|
|
use App\Models\Sector;
|
|
use App\Models\User;
|
|
use App\Services\CountryCatalogue;
|
|
use App\Services\FundingProgrammes;
|
|
use App\Services\InvestmentGeography;
|
|
use App\Services\InvestmentOpportunityAlerts;
|
|
use App\Services\InvestmentWorkspace;
|
|
use App\Services\OpportunityManagement;
|
|
use App\Services\OrganizationCountry;
|
|
use App\Services\Procurement;
|
|
use Illuminate\Http\JsonResponse;
|
|
use Illuminate\Http\RedirectResponse;
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Support\Facades\Crypt;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Str;
|
|
use Inertia\Inertia;
|
|
use Inertia\Response;
|
|
|
|
class InvestmentWorkspaceController extends Controller
|
|
{
|
|
public function profile(Request $request, InvestmentWorkspace $workspace): Response|JsonResponse
|
|
{
|
|
$data = ['organizationCountryAvailable' => 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);
|
|
$alerts = app(InvestmentOpportunityAlerts::class);
|
|
$available = $alerts->installed();
|
|
if ($available) {
|
|
$query->where(function ($sources) use ($request, $alerts) {
|
|
$sources->whereIn('opportunity_id', Opportunity::published()->select('id'))
|
|
->orWhere(function ($legacy) use ($request) {
|
|
$legacy->whereNotNull('submission_id');
|
|
if (! $request->user()->can('manage-portal')) {
|
|
$legacy->whereIn('submission_id', InvestmentSubmission::where('user_id', $request->user()->id)->select('id'));
|
|
}
|
|
});
|
|
if ($alerts->tendersAvailable()) {
|
|
$sources->orWhereIn('tender_version_id', app(Procurement::class)->readableVersions()->select('id'));
|
|
}
|
|
if ($alerts->fundingAvailable()) {
|
|
$sources->orWhereIn('funding_version_id', app(FundingProgrammes::class)->readableVersions()->select('id'));
|
|
}
|
|
});
|
|
} elseif (! $request->user()->can('manage-portal')) {
|
|
$query->whereIn('submission_id', InvestmentSubmission::where('user_id', $request->user()->id)->select('id'));
|
|
}
|
|
$data = ['alertPreferences' => $available ? $alerts->preferences($request->user()->id) : null,
|
|
'alertOptions' => $available ? ['frequencies' => InvestmentOpportunityAlerts::FREQUENCIES,
|
|
'sectors' => Sector::choices()->whereNotNull('published_at')->orderBy('name')->get(['slug', 'name']),
|
|
'provinces' => app(InvestmentGeography::class)->publicReferences()->where('location_type', 'province')->orderBy('name')->get(['id', 'name']),
|
|
'categories' => OpportunityManagement::CATEGORIES, 'tendersAvailable' => $alerts->tendersAvailable(), 'fundingAvailable' => $alerts->fundingAvailable(),
|
|
'tenderRegimes' => config('procurement.regimes'), 'tenderActs' => config('procurement.acts')] : null,
|
|
'unread' => (clone $query)->whereNull('read_at')->count(), 'notifications' => $query->orderByDesc('created_at')->orderByDesc('id')->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);
|
|
if ($row->opportunity_id ?? null) {
|
|
abort_unless(Opportunity::published()->whereKey($row->opportunity_id)->exists(), 404);
|
|
} elseif ($row->tender_version_id ?? null) {
|
|
abort_unless(app(InvestmentOpportunityAlerts::class)->tendersAvailable()
|
|
&& app(Procurement::class)->readableVersions()->where('id', $row->tender_version_id)->exists(), 404);
|
|
} elseif ($row->funding_version_id ?? null) {
|
|
abort_unless(app(InvestmentOpportunityAlerts::class)->fundingAvailable()
|
|
&& app(FundingProgrammes::class)->readableVersions()->where('id', $row->funding_version_id)->exists(), 404);
|
|
} else {
|
|
$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<string,mixed> $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)]);
|
|
}
|
|
}
|