Files
Investidor-V2/app/Services/InvestmentOpportunityAlerts.php
T
2026-11-02 20:31:15 +02:00

264 lines
18 KiB
PHP

<?php
namespace App\Services;
use App\Models\Opportunity;
use App\Models\Sector;
use App\Models\User;
use Carbon\CarbonImmutable;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
final class InvestmentOpportunityAlerts
{
public const FREQUENCIES = ['as_available' => 'Na próxima verificação', 'daily' => 'De 24 em 24 horas', 'weekly' => 'De 7 em 7 dias'];
public function fundingAvailable(): bool
{
return Schema::hasTable('investment_funding_alert_seen') && Schema::hasColumn('investment_notifications', 'funding_version_id')
&& app(FundingProgrammes::class)->installed();
}
/** @param array<string,mixed> $criteria
* @return array<string,mixed> */
private function fundingFilters(array $criteria): array
{
return array_filter(['sector' => isset($criteria['sector']) ? Sector::choices()->where('slug', $criteria['sector'])->value('id') : null,
'territory' => $criteria['province_id'] ?? null, 'certainty' => $criteria['funding_certainty'] ?? 'documented',
'state' => $criteria['funding_state'] ?? null], fn ($value) => $value !== null);
}
public function tendersInstalled(): bool
{
return Schema::hasTable('investment_tender_alert_seen') && Schema::hasColumn('investment_alert_preferences', 'sources')
&& Schema::hasColumn('investment_notifications', 'tender_version_id');
}
public function tendersAvailable(): bool
{
return $this->tendersInstalled() && app(Procurement::class)->available();
}
public function installed(): bool
{
return Schema::hasTable('investment_alert_seen') && Schema::hasTable('investment_alert_preferences') && Schema::hasColumn('investment_notifications', 'opportunity_id');
}
/** @return array<string,mixed> */
public function preferences(int $user): array
{
$row = DB::table('investment_alert_preferences')->where('user_id', $user)->first();
$criteria = $row ? json_decode(Crypt::decryptString($row->criteria), true, 512, JSON_THROW_ON_ERROR) : [];
$sources = isset($row->sources) ? json_decode($row->sources, true, 512, JSON_THROW_ON_ERROR) : ['opportunities'];
$statuses = [];
foreach ($sources as $source) {
$statuses[$source] = $this->criteriaAvailable($criteria, $source) && ($source !== 'tenders' || $this->tendersAvailable())
&& ($source !== 'funding' || $this->fundingAvailable());
}
return ['version' => (int) ($row->version ?? 0), 'enabled' => (bool) ($row->enabled ?? false),
'frequency' => $row->frequency ?? 'as_available', 'channel' => 'in_app', 'next_run_at' => $row->next_run_at ?? null,
'criteria' => $criteria, 'sources' => $sources, 'source_statuses' => $statuses, 'criteria_available' => ! in_array(false, $statuses, true)];
}
/** @param array<string,mixed> $criteria */
private function criteriaAvailable(array $criteria, string $source): bool
{
return (! isset($criteria['sector']) || Sector::choices()->whereNotNull('published_at')->where('slug', $criteria['sector'])->exists())
&& (! isset($criteria['province_id']) || app(InvestmentGeography::class)->publicReferences()->where('location_type', 'province')->where('id', $criteria['province_id'])->exists())
&& ($source !== 'opportunities' || ! isset($criteria['category']) || in_array($criteria['category'], OpportunityManagement::CATEGORIES, true))
&& ($source !== 'tenders' || ! isset($criteria['tender_regime']) || array_key_exists($criteria['tender_regime'], config('procurement.regimes')))
&& ($source !== 'funding' || ((! isset($criteria['funding_certainty']) || in_array($criteria['funding_certainty'], ['documented', 'include_unknown'], true))
&& (! isset($criteria['funding_state']) || in_array($criteria['funding_state'], ['open', 'closed', 'upcoming', 'unknown'], true))));
}
/** @param array<string,mixed> $input
* @return array<string,mixed> */
public function save(User $actor, array $input): array
{
abort_unless($this->installed(), 503);
$enabling = in_array($input['enabled'] ?? null, [true, 1, '1'], true);
// Cancelling only needs the actor and version; retired form choices must not prevent it.
$validatedInput = $enabling ? $input : ['version' => $input['version'] ?? null, 'enabled' => $input['enabled'] ?? null];
if ($enabling) {
$validatedInput['sources'] ??= ['opportunities'];
if (is_array($validatedInput['sources']) && is_array($validatedInput['criteria'] ?? null)) {
if (! in_array('opportunities', $validatedInput['sources'], true)) {
unset($validatedInput['criteria']['category']);
}
if (! in_array('tenders', $validatedInput['sources'], true)) {
unset($validatedInput['criteria']['tender_regime']);
}
if (! in_array('funding', $validatedInput['sources'], true)) {
unset($validatedInput['criteria']['funding_certainty'], $validatedInput['criteria']['funding_state']);
}
}
}
$data = Validator::make($validatedInput, [
'version' => ['required', 'integer', 'min:0'], 'enabled' => ['required', 'boolean'],
'frequency' => [$enabling ? 'required' : 'sometimes', Rule::in(array_keys(self::FREQUENCIES))], 'channel' => [$enabling ? 'required' : 'sometimes', 'in:in_app'],
'consent' => $enabling ? ['accepted'] : ['nullable', 'boolean'],
'sources' => [$enabling ? 'required' : 'sometimes', 'array', 'min:1', 'max:3'],
'sources.*' => ['required', 'string', 'distinct', Rule::in(['opportunities', 'tenders', 'funding'])],
'criteria' => [$enabling ? 'present' : 'sometimes', 'array:sector,province_id,category,tender_regime,funding_certainty,funding_state'],
'criteria.sector' => ['nullable', 'string', 'max:80', ...($enabling ? [Rule::in(Sector::choices()->whereNotNull('published_at')->pluck('slug')->all())] : [])],
'criteria.province_id' => ['nullable', 'uuid'], 'criteria.category' => ['nullable', Rule::in(OpportunityManagement::CATEGORIES)],
'criteria.tender_regime' => ['nullable', Rule::in(array_keys(config('procurement.regimes')))],
'criteria.funding_certainty' => ['nullable', 'in:documented,include_unknown'], 'criteria.funding_state' => ['nullable', 'in:open,closed,upcoming,unknown'],
])->validate();
$sources = $data['sources'] ?? ['opportunities'];
sort($sources);
if ($enabling && in_array('funding', $sources, true) && ! $this->fundingAvailable()) {
throw ValidationException::withMessages(['sources' => 'O catálogo de fundos não está disponível. Pode manter as outras fontes ou cancelar.']);
}
if ($enabling && in_array('tenders', $sources, true) && ! $this->tendersAvailable()) {
throw ValidationException::withMessages(['sources' => 'O catálogo de concursos não está disponível. Pode manter o acompanhamento de oportunidades ou cancelar.']);
}
$criteria = array_filter($data['criteria'] ?? [], fn ($value) => $value !== null && $value !== '');
ksort($criteria);
if ($enabling && isset($criteria['province_id']) && ! app(InvestmentGeography::class)->publicReferences()->where('location_type', 'province')->where('id', $criteria['province_id'])->exists()) {
throw ValidationException::withMessages(['criteria.province_id' => 'Seleccione uma província do cadastro público.']);
}
return DB::transaction(function () use ($actor, $data, $criteria, $sources): array {
// Keep preference writes serialized without blocking notification/audit FK readers.
DB::table('users')->where('id', $actor->id)->lock('FOR NO KEY UPDATE')->firstOrFail();
$row = DB::table('investment_alert_preferences')->where('user_id', $actor->id)->lockForUpdate()->first();
$before = $this->preferences($actor->id);
if (! $data['enabled']) {
$data['frequency'] = $before['frequency'];
$criteria = $before['criteria'];
$sources = $before['sources'];
}
$same = $row && $before['enabled'] === (bool) $data['enabled'] && $before['frequency'] === $data['frequency'] && $before['criteria'] === $criteria && $before['sources'] === $sources;
if ($same && in_array((int) $data['version'], [$before['version'], $before['version'] - 1], true)) {
return $before;
}
abort_unless((int) $data['version'] === $before['version'], 409, 'As preferências foram alteradas. Recarregue antes de guardar.');
if ($data['enabled'] && (! $before['enabled'] || $criteria !== $before['criteria'] || $sources !== $before['sources'])) {
// Existing public identities establish the baseline; no timestamps or backdated publications are guessed.
if (in_array('opportunities', $sources, true)) {
Opportunity::published()->select('id')->chunkById(500, function ($records) use ($actor): void {
DB::table('investment_alert_seen')->insertOrIgnore($records->map(fn ($record) => [
'user_id' => $actor->id, 'opportunity_id' => $record->id, 'observed_at' => now(),
])->all());
});
}
if (in_array('tenders', $sources, true)) {
app(Procurement::class)->publicQuery()->select('v.id as version_id')->chunkById(500, function ($records) use ($actor): void {
DB::table('investment_tender_alert_seen')->insertOrIgnore($records->map(fn ($record) => [
'user_id' => $actor->id, 'tender_version_id' => $record->version_id, 'observed_at' => now(),
])->all());
}, 'v.id', 'version_id');
}
if (in_array('funding', $sources, true)) {
app(FundingProgrammes::class)->publicQuery()->select('v.id as version_id')->chunkById(500, function ($records) use ($actor): void {
DB::table('investment_funding_alert_seen')->insertOrIgnore($records->map(fn ($record) => [
'user_id' => $actor->id, 'funding_version_id' => $record->version_id, 'observed_at' => now(),
])->all());
}, 'v.id', 'version_id');
}
}
$next = ! $data['enabled'] ? null : (($same || ($row && $before['enabled'] && $before['frequency'] === $data['frequency'])) ? $row->next_run_at : $this->nextRun($data['frequency']));
$id = $row->id ?? (string) Str::uuid();
DB::table('investment_alert_preferences')->updateOrInsert(['user_id' => $actor->id], [
'id' => $id,
'version' => $before['version'] + 1, 'enabled' => (bool) $data['enabled'], 'frequency' => $data['frequency'], 'channel' => 'in_app',
'criteria' => Crypt::encryptString(json_encode($criteria, JSON_THROW_ON_ERROR)),
'consented_at' => $data['enabled'] ? now() : ($row->consented_at ?? null), 'next_run_at' => $next,
'created_at' => $row->created_at ?? now(), 'updated_at' => now(),
...(Schema::hasColumn('investment_alert_preferences', 'sources') ? ['sources' => json_encode($sources, JSON_THROW_ON_ERROR)] : []),
]);
DB::table('audit_events')->insert(['source_system' => 'investment-alerts', 'entity_type' => 'investment_alert_preferences',
'entity_id' => $id, 'actor_user_id' => $actor->id, 'action' => $data['enabled'] ? 'preferences_saved' : 'preferences_cancelled',
'data_classification' => 'restricted', 'before_data' => json_encode(['version' => $before['version'], 'enabled' => $before['enabled']], JSON_THROW_ON_ERROR),
'after_data' => json_encode(['version' => $before['version'] + 1, 'enabled' => (bool) $data['enabled'], 'frequency' => $data['frequency'],
'channel' => 'in_app', 'sources' => $sources, 'criteria_sha256' => hash('sha256', json_encode($criteria, JSON_THROW_ON_ERROR))], JSON_THROW_ON_ERROR)]);
return $this->preferences($actor->id);
});
}
private function nextRun(string $frequency): CarbonImmutable
{
return match ($frequency) {
'daily' => now()->addDay(), 'weekly' => now()->addWeek(), default => now(),
};
}
public function dispatchDue(): int
{
if (! $this->installed()) {
return 0;
}
$count = 0;
DB::table('investment_alert_preferences')->where('enabled', true)->where('next_run_at', '<=', now())
->whereIn('user_id', DB::table('users')->whereNotNull('email_verified_at')->select('id'))
->orderBy('user_id')->chunkById(100, function ($preferences) use (&$count): void {
foreach ($preferences as $preference) {
$count += DB::transaction(function () use ($preference): int {
$row = DB::table('investment_alert_preferences')->where('user_id', $preference->user_id)->lockForUpdate()->first();
if (! $row || ! $row->enabled || ! $row->next_run_at || CarbonImmutable::parse($row->next_run_at)->isFuture()) {
return 0;
}
$criteria = json_decode(Crypt::decryptString($row->criteria), true, 512, JSON_THROW_ON_ERROR);
$sources = isset($row->sources) ? json_decode($row->sources, true, 512, JSON_THROW_ON_ERROR) : ['opportunities'];
$delivered = 0;
$more = false;
if (in_array('opportunities', $sources, true) && $this->criteriaAvailable($criteria, 'opportunities')) {
$records = app(InvestmentCatalogue::class)->query(Arr::except($criteria, ['tender_regime', 'funding_certainty', 'funding_state']))->whereNotExists(fn ($seen) => $seen->selectRaw('1')
->from('investment_alert_seen')->where('user_id', $row->user_id)->whereColumn('opportunity_id', 'opportunities.id'))
->orderBy('opportunities.id')->limit(201)->get();
foreach ($records->take(200) as $record) {
$notification = app(InvestmentWorkspace::class)->notifyOpportunity((int) $row->user_id, $record, (int) $row->version, $criteria);
DB::table('investment_alert_seen')->insert(['user_id' => $row->user_id, 'opportunity_id' => $record->id, 'notification_id' => $notification, 'observed_at' => now()]);
}
$delivered += min(200, $records->count());
$more = $records->count() > 200;
}
if (in_array('tenders', $sources, true) && $this->tendersAvailable() && $this->criteriaAvailable($criteria, 'tenders')) {
$filters = array_filter(['territory' => $criteria['province_id'] ?? null, 'regime' => $criteria['tender_regime'] ?? null], fn ($value) => $value !== null);
if (isset($criteria['sector'])) {
$filters['sector_ids'] = Sector::whereIn('slug', SectorOrganization::slugs($criteria['sector']))->pluck('id')->all();
}
$records = app(Procurement::class)->publicQuery($filters)->whereNotExists(fn ($seen) => $seen->selectRaw('1')
->from('investment_tender_alert_seen')->where('user_id', $row->user_id)->whereColumn('tender_version_id', 'v.id'))
->select('t.id as tender_id', 'v.id as version_id', 'v.version', 'v.act', 'v.payload', 'v.published_at')->orderBy('v.id')->limit(201)->get();
foreach ($records->take(200) as $record) {
$notification = app(InvestmentWorkspace::class)->notifyTender((int) $row->user_id, $record, (int) $row->version, $criteria);
DB::table('investment_tender_alert_seen')->insert(['user_id' => $row->user_id, 'tender_version_id' => $record->version_id, 'notification_id' => $notification, 'observed_at' => now()]);
}
$delivered += min(200, $records->count());
$more = $more || $records->count() > 200;
}
if (in_array('funding', $sources, true) && $this->fundingAvailable() && $this->criteriaAvailable($criteria, 'funding')) {
$filters = $this->fundingFilters($criteria);
$records = app(FundingProgrammes::class)->catalogueQuery($filters)->whereNotExists(fn ($seen) => $seen->selectRaw('1')
->from('investment_funding_alert_seen')->where('user_id', $row->user_id)->whereColumn('funding_version_id', 'v.id'))
->select('p.id as programme_id', 'v.id as version_id', 'v.version', 'v.payload', 'v.published_at')->orderBy('v.id')->limit(201)->get();
foreach ($records->take(200) as $record) {
$notification = app(InvestmentWorkspace::class)->notifyFunding((int) $row->user_id, $record, (int) $row->version, $criteria, $filters);
DB::table('investment_funding_alert_seen')->insert(['user_id' => $row->user_id, 'funding_version_id' => $record->version_id, 'notification_id' => $notification, 'observed_at' => now()]);
}
$delivered += min(200, $records->count());
$more = $more || $records->count() > 200;
}
$next = $more ? now() : ($row->frequency === 'as_available' ? now()->addMinutes(5) : $this->nextRun($row->frequency));
DB::table('investment_alert_preferences')->where('user_id', $row->user_id)->update(['next_run_at' => $next]);
return $delivered;
});
}
}, 'user_id');
return $count;
}
}