264 lines
18 KiB
PHP
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;
|
|
}
|
|
}
|