'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 $criteria * @return array */ 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 */ 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 $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 $input * @return array */ 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; } }