diff --git a/CLAUDE.md b/CLAUDE.md index 562420c6..036b19dd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,6 +1,6 @@ # CLAUDE.md — техконтекст Лидерры -**Версия:** 1.64 от 09.05.2026 +**Версия:** 1.65 от 09.05.2026 **Назначение:** оперативная карта для Claude Code. Не первоисточник — первоисточники указаны в §0. > **Ребрендинг 08.05.2026:** «Лидпоток» → **«Лидерра.»** (с точкой). Палитра, лого и шрифты — из handoff Платона (v8 Forest). Применяется только к дизайну/имени/логотипу; функционал, состав страниц и правила — без изменений (источник — ТЗ v8.5/schema v8.5). @@ -224,6 +224,8 @@ trivy image liderra:latest --- +*CLAUDE.md v1.65 от 09.05.2026. Изменения v1.65: **P0 этап 1 — NotificationService + new_lead email** (старт closing TODO «Notification delivery» из карты остатка работы). Закрывает первый из 6 этапов плана P0 (notifications + reminders). **(1) `App\Services\NotificationService`** — центральный диспетчер. Константы 8 событий (new_lead/reminder/low_balance/zero_balance/topup_success/invoice_paid/new_device_login/marketing) + 3 каналов (inapp/push/email) точно как в schema.sql:699 `users.notification_preferences` JSONB DEFAULT. Метод `notifyNewLead(Tenant, Deal)` — выбирает активных user'ов тенанта (is_active=true + deleted_at IS NULL) с включённым `notification_preferences.new_lead.email=true` и шлёт через `Mail::to(...)->send(NewLeadNotification)`. Throwable из Mail-фасада ловится → Log::warning (отказ канала не должен валить транзакцию webhook'а). **PHP-фильтр** prefs (не JSONB-запрос) — список получателей <50 на тенант, не critical-path. **(2) `App\Mail\NewLeadNotification`** — Mailable с (User $manager, Deal $deal, Tenant $tenant). Subject `«Лидерра. Новый лид — {project_name}»` с fallback project=`'Без проекта'` если relation не загружен. **`resources/views/emails/new_lead.blade.php`** — HTML-письмо в Forest-палитре (#0F6E56 primary, #F6F3EC ivory) с таблицей phone/contact_name/received_at (TZ конвертирована в `manager->timezone ?? 'Europe/Moscow'`)/deal_id. **(3) Интеграция `ProcessWebhookJob::chargeNewLead`** — после ActivityLog::create вызов `app(NotificationService::class)->notifyNewLead($tenant, $deal)`. `$deal->setRelation('project', $project)` чтобы Mailable не делал лишний SELECT. NotifyNewLead вне DB::transaction в смысле что ошибка отправки уже вне транзакции — но DB::transaction обёртка сейчас покрывает и notify-вызов; на prod надо или вынести notify ПОСЛЕ DB::transaction, или `Mail::queue` (async через worker). На MVP — sync через ::send (детерминированно для тестов). **(4) Pest +11** в `tests/Feature/Notifications/NewLeadNotificationTest.php`(всего **280/280 за 31.27 сек**, 1029 assertions): Mail::fake() / 1 user с email=true получает / user с email=false не получает / schema-default (.email=false) не шлёт / 2 user'а с email=true получают оба, 3-й с email=false не получает / inactive user с email=true не получает / soft-deleted user не получает / user другого тенанта не получает (изоляция) / Биз-19 дубль не шлёт повторное уведомление / повторный vid (idempotent UPDATE) не шлёт повторно / balance=0 (RejectedDealsLog) не шлёт / subject содержит project_name «Caranga». **(5) IDE-helper** регенерирован (`ide-helper:models -W -M -N`) — добавил @mixin docblocks 4 моделям (ImpersonationToken/SaasAdminAuditLog/SystemSetting/UserRecoveryCode), которые ранее без них работали через baseline-ignore'ы. **PHPStan baseline регенерирован** — 138 «ignore.unmatched» errors схлопнулись (новые docblocks резолвят property access напрямую, baseline-патч больше не нужен). **Производственные TODO остаточные:** этапы 2–6 P0 (in_app_notifications + UI bell, NotificationsTab fix под schema, reminders backend+frontend, остальные 4 email-события); SaaS-admin auth ⏸ Б-1; Pest browser-mode ⏸ инфра. **Регресс зелёный:** Pint+PHPStan passed (baseline регенерирован); **Pest 280/280 за 31.27 сек** (+11 от 269, 1029 assertions); frontend нетронут — Vitest/build не нужны. Реестр без изменений (notifications не было в открытых вопросах). v1.64→v1.65.* + *CLAUDE.md v1.64 от 09.05.2026. Изменения v1.64: **«Корзина» для soft-deleted сделок** — естественное продолжение stages 5/6 (soft-delete + restore). Расширяет undo-snackbar (8 сек window) до постоянного доступа к удалённым через отдельный view-mode. **(1) Backend `DealController::index`** — query-param `only_deleted=true` (boolean-like) активирует branch `Deal::query()->withTrashed()->whereNotNull('deleted_at')` (обход global scope SoftDeletes + явный фильтр для NO-OP idempotency). Все остальные фильтры (status_in/project_id/manager_id/search/limit/offset) применимы и в trash-mode. **(2) Pest +3** в `DealIndexTest` (всего **269/269 за 29.12 сек**, 1009 assertions): only_deleted=true возвращает только soft-deleted (3 deals: 1 alive + 2 deleted → total=2) / без only_deleted soft-deleted скрыты (default behavior сохранён) / RLS+app-фильтр изолирует чужие удалённые сделки. **(3) Frontend `ListDealsParams.onlyDeleted?: boolean`** в типе + axios mapping `only_deleted: 'true' | undefined`. **DealsView расширен:** `trashMode` ref, `toggleTrashMode()` (clear selected + reload), `applyBulkRestoreFromTrash()` (optimistic remove from list + bulkRestoreDeals + toast). **UI changes в trash-mode:** заголовок «Сделки» → «Корзина» / btn `mdi-arrow-left К сделкам` (warning-flat) вместо `mdi-trash-can-outline Корзина` (outlined) / hide «Экспорт» + «Новая сделка» / hide chiprow filter-bar (не имеет смысла для удалённых) / info-alert «Корзина: показаны удалённые сделки» / bulk-bar заменяется: только `mdi-restore Восстановить` (success-tonal) + clear-btn (status/export/delete скрыты). **(4) Vitest +2** в DealsListIntegration (всего **321/321 за 19.60 сек**, +2 от 319): toggleTrashMode переключает trashMode + listDeals вызывается с onlyDeleted=true / applyBulkRestoreFromTrash вызывает bulkRestoreDeals + убирает из dealsState + toast «Восстановлено 2». **PHPStan baseline**: без изменений. **Production TODO остаточные:** SaaS-admin auth ⏸ Б-1; Pest browser-mode ⏸ инфра. **Регресс зелёный:** lint+type-check+format ✅; **Vitest 321/321 за 19.60 сек** (+2 от 319); vite build 1.04 сек; Pint+PHPStan passed; **Pest 269/269 за 29.12 сек** (+3 от 266, 1009 assertions). Реестр v1.72→v1.73.* *CLAUDE.md v1.63 от 09.05.2026. Изменения v1.63: **Polling 30 сек** — закрывает последний unblocked production-TODO «Polling/SSE для real-time». Manual reload-btn остаётся как fast-path; polling — фоновый автообновитель. **(1) Composable `composables/usePolling.ts`** — `usePolling(loader, {intervalMs?, enabled?})`. По умолчанию 30_000 ms. **Page Visibility API integration**: при `document.hidden=true` interval останавливается + skip-проверка внутри tick (defense-in-depth); при `visibilitychange` event с `hidden=false` — restart interval + немедленный `loader()` (не ждать следующего interval'а). Cleanup на `onBeforeUnmount` — clearInterval + removeEventListener. `enabled=false` — composable не стартует совсем (для feature-flag'а). **(2) Integration в 5 view'ов:** DealsView+KanbanView (вызывают `loadDeals`), AdminTenantsView (`loadTenants`), AdminBillingView (`loadBilling`), AdminIncidentsView (`loadIncidents`). Без auth.user.tenant_id loadDeals — no-op (в самой функции return на отсутствие tenant_id), так что polling без auth ничего не делает. **(3) Vitest +6** в `usePolling.spec.ts` (всего **319/319 за 18.67 сек**, +6 от 313): через `vi.useFakeTimers` + `vi.advanceTimersByTime` для детерминированности. Тесты: вызов каждые intervalMs / default 30 сек / skip при document.hidden=true / cleanup на unmount / enabled=false → no-op / visibilitychange pause+resume с немедленным loader. **PHPStan baseline**: без изменений (frontend-only коммит). **Production TODO остаточные:** SaaS-admin auth ⏸ Б-1; Pest browser-mode ⏸ инфра. **Регресс зелёный:** lint+type-check+format ✅; **Vitest 319/319 за 18.67 сек** (+6 от 313); vite build 899 ms; Pint+PHPStan passed; **Pest 266/266 за 28.62 сек** (без изменений — backend не тронут). Реестр v1.71→v1.72.* diff --git a/app/app/Jobs/ProcessWebhookJob.php b/app/app/Jobs/ProcessWebhookJob.php index 2be98fc6..d697d912 100644 --- a/app/app/Jobs/ProcessWebhookJob.php +++ b/app/app/Jobs/ProcessWebhookJob.php @@ -13,6 +13,7 @@ use App\Models\RejectedDealsLog; use App\Models\SupplierLeadCost; use App\Models\Tenant; use App\Services\DuplicateDetector; +use App\Services\NotificationService; use App\Services\SupplierResolver; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; @@ -41,8 +42,11 @@ use Throwable; * сделке проставляется `duplicate_of_id`, баланс НЕ списывается, SupplierLeadCost * НЕ создаётся. ActivityLog пишется с context.duplicate_of=master.id. * + * Уведомления (ТЗ §18.5, событие new_lead): после успешного chargeNewLead + * вызывается NotificationService::notifyNewLead, который рассылает email + * всем активным user'ам тенанта с включённым каналом email для new_lead. + * * Не входит в текущий PoC (отдельные ветви фазы 1): - * - SendNewLeadNotificationJob (Биз-20) * - Sentry::captureException в failed() (нет Sentry-DSN на dev-стеке) * - SystemSetting fallback для supplier_id (сейчас лукап через project_suppliers) */ @@ -216,6 +220,13 @@ class ProcessWebhookJob implements ShouldQueue 'context' => ['source' => 'webhook'], 'created_at' => now(), ]); + + // Уведомление о новом лиде (ТЗ §18.5). Отправляется ПОСЛЕ всех записей + // в БД, чтобы при ошибке отправки транзакция уже была зафиксирована. + // NotificationService сам ловит Throwable от Mail::send и логирует — + // отказ канала не должен валить webhook. + $deal->setRelation('project', $project); + app(NotificationService::class)->notifyNewLead($tenant, $deal); } /** diff --git a/app/app/Mail/NewLeadNotification.php b/app/app/Mail/NewLeadNotification.php new file mode 100644 index 00000000..a203d855 --- /dev/null +++ b/app/app/Mail/NewLeadNotification.php @@ -0,0 +1,55 @@ +deal->project?->name ?? 'Без проекта'; + + return new Envelope( + subject: "Лидерра. Новый лид — {$projectName}", + ); + } + + public function content(): Content + { + return new Content( + view: 'emails.new_lead', + with: [ + 'manager' => $this->manager, + 'deal' => $this->deal, + 'tenant' => $this->tenant, + 'projectName' => $this->deal->project?->name ?? 'Без проекта', + ], + ); + } +} diff --git a/app/app/Models/ImpersonationToken.php b/app/app/Models/ImpersonationToken.php index 2eedbc12..2d187608 100644 --- a/app/app/Models/ImpersonationToken.php +++ b/app/app/Models/ImpersonationToken.php @@ -32,6 +32,8 @@ use Illuminate\Support\Carbon; * @property int|null $second_approver_id * @property Carbon|null $second_approval_at * @property Carbon $created_at + * + * @mixin IdeHelperImpersonationToken */ class ImpersonationToken extends Model { diff --git a/app/app/Models/SaasAdminAuditLog.php b/app/app/Models/SaasAdminAuditLog.php index 30067f69..74abde0c 100644 --- a/app/app/Models/SaasAdminAuditLog.php +++ b/app/app/Models/SaasAdminAuditLog.php @@ -30,6 +30,8 @@ use Illuminate\Support\Carbon; * @property int|null $approved_by * @property Carbon|null $approved_at * @property Carbon $created_at + * + * @mixin IdeHelperSaasAdminAuditLog */ class SaasAdminAuditLog extends Model { diff --git a/app/app/Models/SystemSetting.php b/app/app/Models/SystemSetting.php index d017f82a..873af8e3 100644 --- a/app/app/Models/SystemSetting.php +++ b/app/app/Models/SystemSetting.php @@ -18,6 +18,8 @@ use Illuminate\Support\Carbon; * @property string|null $description * @property Carbon $updated_at * @property int|null $updated_by + * + * @mixin IdeHelperSystemSetting */ class SystemSetting extends Model { diff --git a/app/app/Models/UserRecoveryCode.php b/app/app/Models/UserRecoveryCode.php index 502122bc..1318478d 100644 --- a/app/app/Models/UserRecoveryCode.php +++ b/app/app/Models/UserRecoveryCode.php @@ -19,6 +19,8 @@ use Illuminate\Support\Carbon; * @property string $code_hash * @property Carbon|null $used_at * @property Carbon|null $created_at + * + * @mixin IdeHelperUserRecoveryCode */ class UserRecoveryCode extends Model { diff --git a/app/app/Services/NotificationService.php b/app/app/Services/NotificationService.php new file mode 100644 index 00000000..d1e104ae --- /dev/null +++ b/app/app/Services/NotificationService.php @@ -0,0 +1,139 @@ +recipientsForEvent($tenant, self::EVENT_NEW_LEAD, self::CHANNEL_EMAIL); + + foreach ($recipients as $user) { + $this->sendEmail($user, self::EVENT_NEW_LEAD, new NewLeadNotification($user, $deal, $tenant)); + } + } + + /** + * Активные user'ы тенанта с включённой подпиской на (event, channel). + * Soft-deleted и is_active=false исключены. Для тестов: маленькие + * списки получателей (<50 на тенант), фильтр в PHP вместо JSONB-запроса. + * + * @return Collection + */ + private function recipientsForEvent(Tenant $tenant, string $event, string $channel): Collection + { + return User::query() + ->where('tenant_id', $tenant->id) + ->where('is_active', true) + ->whereNull('deleted_at') + ->get() + ->filter(fn (User $user): bool => $this->prefEnabled($user, $event, $channel)) + ->values(); + } + + /** + * Читает users.notification_preferences[event][channel]. NULL/missing → false. + */ + private function prefEnabled(User $user, string $event, string $channel): bool + { + $prefs = $user->notification_preferences; + + if (! is_array($prefs)) { + return false; + } + + return (bool) ($prefs[$event][$channel] ?? false); + } + + /** + * Отправка email через Mail-фасад. На dev (MAIL_MAILER=log) пишется в + * storage/logs/laravel.log, на prod — Unisender Go (SMTP relay). + * + * Используется Mail::send (sync) для простоты отладки. Для prod + * рекомендуется Mail::queue (async через worker), чтобы webhook + * worker не блокировался на SMTP. Бросаемые исключения проглатываются + * — отказ канала уведомления не должен валить транзакцию сделки. + */ + private function sendEmail(User $user, string $event, $mailable): void + { + try { + Mail::to($user->email)->send($mailable); + } catch (Throwable $e) { + Log::warning('notification.email_failed', [ + 'user_id' => $user->id, + 'event' => $event, + 'error' => $e->getMessage(), + ]); + } + } +} diff --git a/app/phpstan-baseline.neon b/app/phpstan-baseline.neon index 60f8664d..e3ea86e8 100644 --- a/app/phpstan-baseline.neon +++ b/app/phpstan-baseline.neon @@ -1,77 +1,5 @@ parameters: ignoreErrors: - - - message: '#^Access to an undefined property App\\Models\\Tenant\:\:\$balance_leads\.$#' - identifier: property.notFound - count: 1 - path: app/Http/Controllers/Api/AdminTenantsController.php - - - - message: '#^Access to an undefined property App\\Models\\Tenant\:\:\$balance_rub\.$#' - identifier: property.notFound - count: 1 - path: app/Http/Controllers/Api/AdminTenantsController.php - - - - message: '#^Access to an undefined property App\\Models\\Tenant\:\:\$chargeback_unrecovered_rub\.$#' - identifier: property.notFound - count: 1 - path: app/Http/Controllers/Api/AdminTenantsController.php - - - - message: '#^Access to an undefined property App\\Models\\Tenant\:\:\$contact_email\.$#' - identifier: property.notFound - count: 1 - path: app/Http/Controllers/Api/AdminTenantsController.php - - - - message: '#^Access to an undefined property App\\Models\\Tenant\:\:\$created_at\.$#' - identifier: property.notFound - count: 1 - path: app/Http/Controllers/Api/AdminTenantsController.php - - - - message: '#^Access to an undefined property App\\Models\\Tenant\:\:\$current_tariff_id\.$#' - identifier: property.notFound - count: 1 - path: app/Http/Controllers/Api/AdminTenantsController.php - - - - message: '#^Access to an undefined property App\\Models\\Tenant\:\:\$desired_daily_numbers\.$#' - identifier: property.notFound - count: 1 - path: app/Http/Controllers/Api/AdminTenantsController.php - - - - message: '#^Access to an undefined property App\\Models\\Tenant\:\:\$is_trial\.$#' - identifier: property.notFound - count: 2 - path: app/Http/Controllers/Api/AdminTenantsController.php - - - - message: '#^Access to an undefined property App\\Models\\Tenant\:\:\$last_activity_at\.$#' - identifier: property.notFound - count: 1 - path: app/Http/Controllers/Api/AdminTenantsController.php - - - - message: '#^Access to an undefined property App\\Models\\Tenant\:\:\$organization_name\.$#' - identifier: property.notFound - count: 1 - path: app/Http/Controllers/Api/AdminTenantsController.php - - - - message: '#^Access to an undefined property App\\Models\\Tenant\:\:\$status\.$#' - identifier: property.notFound - count: 1 - path: app/Http/Controllers/Api/AdminTenantsController.php - - - - message: '#^Access to an undefined property App\\Models\\Tenant\:\:\$subdomain\.$#' - identifier: property.notFound - count: 1 - path: app/Http/Controllers/Api/AdminTenantsController.php - - message: '#^Access to an undefined property App\\Models\\Tenant\:\:\$tariff_name\.$#' identifier: property.notFound @@ -115,185 +43,11 @@ parameters: path: app/Http/Controllers/Api/AdminTenantsController.php - - message: '#^Access to an undefined property App\\Models\\User\:\:\$email\.$#' - identifier: property.notFound - count: 3 - path: app/Http/Controllers/Api/AuthController.php - - - - message: '#^Access to an undefined property App\\Models\\User\:\:\$first_name\.$#' - identifier: property.notFound - count: 1 - path: app/Http/Controllers/Api/AuthController.php - - - - message: '#^Access to an undefined property App\\Models\\User\:\:\$is_active\.$#' - identifier: property.notFound - count: 1 - path: app/Http/Controllers/Api/AuthController.php - - - - message: '#^Access to an undefined property App\\Models\\User\:\:\$last_login_at\.$#' - identifier: property.notFound - count: 1 - path: app/Http/Controllers/Api/AuthController.php - - - - message: '#^Access to an undefined property App\\Models\\User\:\:\$last_name\.$#' - identifier: property.notFound - count: 1 - path: app/Http/Controllers/Api/AuthController.php - - - - message: '#^Access to an undefined property App\\Models\\User\:\:\$password_hash\.$#' - identifier: property.notFound - count: 1 - path: app/Http/Controllers/Api/AuthController.php - - - - message: '#^Access to an undefined property App\\Models\\User\:\:\$tenant_id\.$#' - identifier: property.notFound - count: 2 - path: app/Http/Controllers/Api/AuthController.php - - - - message: '#^Access to an undefined property App\\Models\\User\:\:\$totp_enabled\.$#' - identifier: property.notFound - count: 2 - path: app/Http/Controllers/Api/AuthController.php - - - - message: '#^Access to an undefined property App\\Models\\User\|Illuminate\\Database\\Eloquent\\Collection\\:\:\$totp_enabled\.$#' - identifier: property.notFound - count: 1 - path: app/Http/Controllers/Api/AuthController.php - - - - message: '#^Access to an undefined property App\\Models\\ActivityLog\:\:\$context\.$#' - identifier: property.notFound - count: 1 - path: app/Http/Controllers/Api/DealController.php - - - - message: '#^Access to an undefined property App\\Models\\ActivityLog\:\:\$created_at\.$#' - identifier: property.notFound - count: 1 - path: app/Http/Controllers/Api/DealController.php - - - - message: '#^Access to an undefined property App\\Models\\ActivityLog\:\:\$event\.$#' - identifier: property.notFound - count: 1 - path: app/Http/Controllers/Api/DealController.php - - - - message: '#^Access to an undefined property App\\Models\\Deal\:\:\$assigned_at\.$#' - identifier: property.notFound + message: '#^Using nullsafe method call on non\-nullable type Illuminate\\Support\\Carbon\. Use \-\> instead\.$#' + identifier: nullsafe.neverNull count: 3 path: app/Http/Controllers/Api/DealController.php - - - message: '#^Access to an undefined property App\\Models\\Deal\:\:\$comment\.$#' - identifier: property.notFound - count: 4 - path: app/Http/Controllers/Api/DealController.php - - - - message: '#^Access to an undefined property App\\Models\\Deal\:\:\$contact_name\.$#' - identifier: property.notFound - count: 4 - path: app/Http/Controllers/Api/DealController.php - - - - message: '#^Access to an undefined property App\\Models\\Deal\:\:\$deleted_at\.$#' - identifier: property.notFound - count: 2 - path: app/Http/Controllers/Api/DealController.php - - - - message: '#^Access to an undefined property App\\Models\\Deal\:\:\$manager_id\.$#' - identifier: property.notFound - count: 7 - path: app/Http/Controllers/Api/DealController.php - - - - message: '#^Access to an undefined property App\\Models\\Deal\:\:\$phone\.$#' - identifier: property.notFound - count: 4 - path: app/Http/Controllers/Api/DealController.php - - - - message: '#^Access to an undefined property App\\Models\\Deal\:\:\$project_id\.$#' - identifier: property.notFound - count: 4 - path: app/Http/Controllers/Api/DealController.php - - - - message: '#^Access to an undefined property App\\Models\\Deal\:\:\$received_at\.$#' - identifier: property.notFound - count: 5 - path: app/Http/Controllers/Api/DealController.php - - - - message: '#^Access to an undefined property App\\Models\\Deal\:\:\$status\.$#' - identifier: property.notFound - count: 10 - path: app/Http/Controllers/Api/DealController.php - - - - message: '#^Access to an undefined property App\\Models\\Deal\:\:\$tenant_id\.$#' - identifier: property.notFound - count: 4 - path: app/Http/Controllers/Api/DealController.php - - - - message: '#^Access to an undefined property App\\Models\\Project\:\:\$name\.$#' - identifier: property.notFound - count: 2 - path: app/Http/Controllers/Api/DealController.php - - - - message: '#^Access to an undefined property App\\Models\\User\:\:\$email\.$#' - identifier: property.notFound - count: 6 - path: app/Http/Controllers/Api/DealController.php - - - - message: '#^Access to an undefined property App\\Models\\User\:\:\$first_name\.$#' - identifier: property.notFound - count: 6 - path: app/Http/Controllers/Api/DealController.php - - - - message: '#^Access to an undefined property App\\Models\\User\:\:\$last_name\.$#' - identifier: property.notFound - count: 6 - path: app/Http/Controllers/Api/DealController.php - - - - message: '#^Parameter \#1 \$callback of method Illuminate\\Database\\Eloquent\\Collection\\:\:map\(\) contains unresolvable type\.$#' - identifier: argument.unresolvableType - count: 1 - path: app/Http/Controllers/Api/DealController.php - - - - message: '#^Parameter \#1 \$callback of method Illuminate\\Database\\Eloquent\\Collection\\:\:map\(\) contains unresolvable type\.$#' - identifier: argument.unresolvableType - count: 1 - path: app/Http/Controllers/Api/DealController.php - - - - message: '#^Access to an undefined property App\\Models\\Tenant\:\:\$contact_email\.$#' - identifier: property.notFound - count: 1 - path: app/Http/Controllers/Api/ImpersonationController.php - - - - message: '#^Access to an undefined property App\\Models\\Tenant\:\:\$organization_name\.$#' - identifier: property.notFound - count: 2 - path: app/Http/Controllers/Api/ImpersonationController.php - - message: '#^Cannot call method toIso8601String\(\) on null\.$#' identifier: method.nonObject @@ -301,160 +55,22 @@ parameters: path: app/Http/Controllers/Api/ImpersonationController.php - - message: '#^Parameter \#1 \$callback of method Illuminate\\Database\\Eloquent\\Collection\\:\:map\(\) contains unresolvable type\.$#' - identifier: argument.unresolvableType + message: '#^Strict comparison using \!\=\= between int and null will always evaluate to true\.$#' + identifier: notIdentical.alwaysTrue + count: 1 + path: app/Http/Middleware/SetTenantContext.php + + - + message: '#^Using nullsafe property access "\?\-\>name" on left side of \?\? is unnecessary\. Use \-\> instead\.$#' + identifier: nullsafe.neverNull count: 2 - path: app/Http/Controllers/Api/ImpersonationController.php + path: app/Mail/NewLeadNotification.php - - message: '#^Access to an undefined property App\\Models\\User\:\:\$email\.$#' - identifier: property.notFound - count: 3 - path: app/Http/Controllers/Api/ManagerController.php - - - - message: '#^Access to an undefined property App\\Models\\User\:\:\$first_name\.$#' - identifier: property.notFound - count: 3 - path: app/Http/Controllers/Api/ManagerController.php - - - - message: '#^Access to an undefined property App\\Models\\User\:\:\$last_name\.$#' - identifier: property.notFound - count: 3 - path: app/Http/Controllers/Api/ManagerController.php - - - - message: '#^Parameter \#1 \$callback of method Illuminate\\Database\\Eloquent\\Collection\\:\:map\(\) contains unresolvable type\.$#' - identifier: argument.unresolvableType + message: '#^Call to function is_array\(\) with array\ will always evaluate to true\.$#' + identifier: function.alreadyNarrowedType count: 1 - path: app/Http/Controllers/Api/ManagerController.php - - - - message: '#^Access to an undefined property App\\Models\\Project\:\:\$name\.$#' - identifier: property.notFound - count: 1 - path: app/Http/Controllers/Api/ProjectController.php - - - - message: '#^Access to an undefined property App\\Models\\Project\:\:\$tag\.$#' - identifier: property.notFound - count: 1 - path: app/Http/Controllers/Api/ProjectController.php - - - - message: '#^Access to an undefined property App\\Models\\Project\:\:\$type\.$#' - identifier: property.notFound - count: 1 - path: app/Http/Controllers/Api/ProjectController.php - - - - message: '#^Parameter \#1 \$callback of method Illuminate\\Database\\Eloquent\\Collection\\:\:map\(\) contains unresolvable type\.$#' - identifier: argument.unresolvableType - count: 1 - path: app/Http/Controllers/Api/ProjectController.php - - - - message: '#^Access to an undefined property App\\Models\\User\:\:\$email\.$#' - identifier: property.notFound - count: 1 - path: app/Http/Controllers/Api/TwoFactorSetupController.php - - - - message: '#^Access to an undefined property App\\Models\\User\:\:\$password_hash\.$#' - identifier: property.notFound - count: 2 - path: app/Http/Controllers/Api/TwoFactorSetupController.php - - - - message: '#^Access to an undefined property App\\Models\\User\:\:\$totp_enabled\.$#' - identifier: property.notFound - count: 2 - path: app/Http/Controllers/Api/TwoFactorSetupController.php - - - - message: '#^Access to an undefined property App\\Models\\Deal\:\:\$received_at\.$#' - identifier: property.notFound - count: 2 - path: app/Jobs/ProcessWebhookJob.php - - - - message: '#^Access to an undefined property App\\Models\\Tenant\:\:\$balance_leads\.$#' - identifier: property.notFound - count: 2 - path: app/Jobs/ProcessWebhookJob.php - - - - message: '#^PHPDoc tag @mixin contains unknown class App\\Models\\IdeHelperActivityLog\.$#' - identifier: class.notFound - count: 1 - path: app/Models/ActivityLog.php - - - - message: '#^PHPDoc tag @mixin contains unknown class App\\Models\\IdeHelperBalanceTransaction\.$#' - identifier: class.notFound - count: 1 - path: app/Models/BalanceTransaction.php - - - - message: '#^PHPDoc tag @mixin contains unknown class App\\Models\\IdeHelperDeal\.$#' - identifier: class.notFound - count: 1 - path: app/Models/Deal.php - - - - message: '#^PHPDoc tag @mixin contains unknown class App\\Models\\IdeHelperFailedWebhookJob\.$#' - identifier: class.notFound - count: 1 - path: app/Models/FailedWebhookJob.php - - - - message: '#^PHPDoc tag @mixin contains unknown class App\\Models\\IdeHelperProject\.$#' - identifier: class.notFound - count: 1 - path: app/Models/Project.php - - - - message: '#^PHPDoc tag @mixin contains unknown class App\\Models\\IdeHelperRejectedDealsLog\.$#' - identifier: class.notFound - count: 1 - path: app/Models/RejectedDealsLog.php - - - - message: '#^PHPDoc tag @mixin contains unknown class App\\Models\\IdeHelperSupplier\.$#' - identifier: class.notFound - count: 1 - path: app/Models/Supplier.php - - - - message: '#^PHPDoc tag @mixin contains unknown class App\\Models\\IdeHelperSupplierLeadCost\.$#' - identifier: class.notFound - count: 1 - path: app/Models/SupplierLeadCost.php - - - - message: '#^PHPDoc tag @mixin contains unknown class App\\Models\\IdeHelperTenant\.$#' - identifier: class.notFound - count: 1 - path: app/Models/Tenant.php - - - - message: '#^Access to an undefined property App\\Models\\User\:\:\$password_hash\.$#' - identifier: property.notFound - count: 1 - path: app/Models/User.php - - - - message: '#^PHPDoc tag @mixin contains unknown class App\\Models\\IdeHelperUser\.$#' - identifier: class.notFound - count: 1 - path: app/Models/User.php - - - - message: '#^PHPDoc tag @mixin contains unknown class App\\Models\\IdeHelperWebhookDedupKey\.$#' - identifier: class.notFound - count: 1 - path: app/Models/WebhookDedupKey.php + path: app/Services/NotificationService.php - message: '#^Return type \(array\\) of method Database\\Factories\\ProjectFactory\:\:definition\(\) should be compatible with return type \(array\\) of method Illuminate\\Database\\Eloquent\\Factories\\Factory\\:\:definition\(\)$#' @@ -522,18 +138,6 @@ parameters: count: 13 path: tests/Feature/AdminTenantsIndexTest.php - - - message: '#^Access to an undefined property App\\Models\\User\:\:\$last_login_at\.$#' - identifier: property.notFound - count: 1 - path: tests/Feature/Auth/AuthControllerTest.php - - - - message: '#^Access to an undefined property App\\Models\\User\:\:\$password_hash\.$#' - identifier: property.notFound - count: 1 - path: tests/Feature/Auth/AuthControllerTest.php - - message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#' identifier: property.notFound @@ -714,72 +318,6 @@ parameters: count: 11 path: tests/Feature/Auth/TwoFactorTest.php - - - message: '#^Access to an undefined property App\\Models\\ActivityLog\:\:\$context\.$#' - identifier: property.notFound - count: 1 - path: tests/Feature/DealCreateTest.php - - - - message: '#^Access to an undefined property App\\Models\\ActivityLog\:\:\$event\.$#' - identifier: property.notFound - count: 1 - path: tests/Feature/DealCreateTest.php - - - - message: '#^Access to an undefined property App\\Models\\Deal\:\:\$assigned_at\.$#' - identifier: property.notFound - count: 1 - path: tests/Feature/DealCreateTest.php - - - - message: '#^Access to an undefined property App\\Models\\Deal\:\:\$contact_name\.$#' - identifier: property.notFound - count: 1 - path: tests/Feature/DealCreateTest.php - - - - message: '#^Access to an undefined property App\\Models\\Deal\:\:\$manager_id\.$#' - identifier: property.notFound - count: 1 - path: tests/Feature/DealCreateTest.php - - - - message: '#^Access to an undefined property App\\Models\\Deal\:\:\$source_crm_id\.$#' - identifier: property.notFound - count: 1 - path: tests/Feature/DealCreateTest.php - - - - message: '#^Access to an undefined property App\\Models\\Project\|Illuminate\\Database\\Eloquent\\Collection\\:\:\$name\.$#' - identifier: property.notFound - count: 1 - path: tests/Feature/DealCreateTest.php - - - - message: '#^Access to an undefined property App\\Models\\Project\|Illuminate\\Database\\Eloquent\\Collection\\:\:\$type\.$#' - identifier: property.notFound - count: 1 - path: tests/Feature/DealCreateTest.php - - - - message: '#^Access to an undefined property App\\Models\\SupplierLeadCost\:\:\$cost_rub\.$#' - identifier: property.notFound - count: 1 - path: tests/Feature/DealCreateTest.php - - - - message: '#^Access to an undefined property App\\Models\\SupplierLeadCost\:\:\$supplier_id\.$#' - identifier: property.notFound - count: 1 - path: tests/Feature/DealCreateTest.php - - - - message: '#^Access to an undefined property App\\Models\\SupplierLeadCost\:\:\$supplier_lead_id\.$#' - identifier: property.notFound - count: 1 - path: tests/Feature/DealCreateTest.php - - message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#' identifier: property.notFound @@ -792,12 +330,6 @@ parameters: count: 25 path: tests/Feature/DealCreateTest.php - - - message: '#^Access to an undefined property App\\Models\\ActivityLog\:\:\$context\.$#' - identifier: property.notFound - count: 1 - path: tests/Feature/DealDestroyTest.php - - message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$otherTenant\.$#' identifier: property.notFound @@ -864,60 +396,6 @@ parameters: count: 17 path: tests/Feature/DealIndexTest.php - - - message: '#^Access to an undefined property App\\Models\\Deal\:\:\$comment\.$#' - identifier: property.notFound - count: 1 - path: tests/Feature/DealModelTest.php - - - - message: '#^Access to an undefined property App\\Models\\Deal\:\:\$escalated_count\.$#' - identifier: property.notFound - count: 1 - path: tests/Feature/DealModelTest.php - - - - message: '#^Access to an undefined property App\\Models\\Deal\:\:\$is_test\.$#' - identifier: property.notFound - count: 2 - path: tests/Feature/DealModelTest.php - - - - message: '#^Access to an undefined property App\\Models\\Deal\:\:\$phones\.$#' - identifier: property.notFound - count: 1 - path: tests/Feature/DealModelTest.php - - - - message: '#^Access to an undefined property App\\Models\\Deal\:\:\$project_id\.$#' - identifier: property.notFound - count: 1 - path: tests/Feature/DealModelTest.php - - - - message: '#^Access to an undefined property App\\Models\\Deal\:\:\$received_at\.$#' - identifier: property.notFound - count: 4 - path: tests/Feature/DealModelTest.php - - - - message: '#^Access to an undefined property App\\Models\\Deal\:\:\$status\.$#' - identifier: property.notFound - count: 1 - path: tests/Feature/DealModelTest.php - - - - message: '#^Access to an undefined property App\\Models\\Deal\:\:\$tenant_id\.$#' - identifier: property.notFound - count: 1 - path: tests/Feature/DealModelTest.php - - - - message: '#^Access to an undefined property App\\Models\\ActivityLog\:\:\$context\.$#' - identifier: property.notFound - count: 1 - path: tests/Feature/DealRestoreTest.php - - message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$otherTenant\.$#' identifier: property.notFound @@ -984,18 +462,6 @@ parameters: count: 8 path: tests/Feature/DealShowTest.php - - - message: '#^Access to an undefined property App\\Models\\ActivityLog\:\:\$context\.$#' - identifier: property.notFound - count: 1 - path: tests/Feature/DealTransitionTest.php - - - - message: '#^Access to an undefined property App\\Models\\Deal\:\:\$status\.$#' - identifier: property.notFound - count: 4 - path: tests/Feature/DealTransitionTest.php - - message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$otherTenant\.$#' identifier: property.notFound @@ -1020,36 +486,6 @@ parameters: count: 7 path: tests/Feature/DealTransitionTest.php - - - message: '#^Access to an undefined property App\\Models\\ActivityLog\:\:\$context\.$#' - identifier: property.notFound - count: 3 - path: tests/Feature/DealUpdateTest.php - - - - message: '#^Access to an undefined property App\\Models\\Deal\:\:\$assigned_at\.$#' - identifier: property.notFound - count: 1 - path: tests/Feature/DealUpdateTest.php - - - - message: '#^Access to an undefined property App\\Models\\Deal\:\:\$comment\.$#' - identifier: property.notFound - count: 1 - path: tests/Feature/DealUpdateTest.php - - - - message: '#^Access to an undefined property App\\Models\\Deal\:\:\$manager_id\.$#' - identifier: property.notFound - count: 1 - path: tests/Feature/DealUpdateTest.php - - - - message: '#^Access to an undefined property App\\Models\\Deal\:\:\$status\.$#' - identifier: property.notFound - count: 2 - path: tests/Feature/DealUpdateTest.php - - message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$manager\.$#' identifier: property.notFound @@ -1134,126 +570,6 @@ parameters: count: 2 path: tests/Feature/PartitionsCreateMonthsTest.php - - - message: '#^Access to an undefined property App\\Models\\ActivityLog\:\:\$context\.$#' - identifier: property.notFound - count: 3 - path: tests/Feature/ProcessWebhookJobTest.php - - - - message: '#^Access to an undefined property App\\Models\\ActivityLog\:\:\$event\.$#' - identifier: property.notFound - count: 1 - path: tests/Feature/ProcessWebhookJobTest.php - - - - message: '#^Access to an undefined property App\\Models\\ActivityLog\:\:\$user_id\.$#' - identifier: property.notFound - count: 1 - path: tests/Feature/ProcessWebhookJobTest.php - - - - message: '#^Access to an undefined property App\\Models\\BalanceTransaction\:\:\$amount_leads\.$#' - identifier: property.notFound - count: 1 - path: tests/Feature/ProcessWebhookJobTest.php - - - - message: '#^Access to an undefined property App\\Models\\BalanceTransaction\:\:\$balance_leads_after\.$#' - identifier: property.notFound - count: 1 - path: tests/Feature/ProcessWebhookJobTest.php - - - - message: '#^Access to an undefined property App\\Models\\BalanceTransaction\:\:\$related_id\.$#' - identifier: property.notFound - count: 1 - path: tests/Feature/ProcessWebhookJobTest.php - - - - message: '#^Access to an undefined property App\\Models\\BalanceTransaction\:\:\$related_type\.$#' - identifier: property.notFound - count: 1 - path: tests/Feature/ProcessWebhookJobTest.php - - - - message: '#^Access to an undefined property App\\Models\\Deal\:\:\$duplicate_of_id\.$#' - identifier: property.notFound - count: 6 - path: tests/Feature/ProcessWebhookJobTest.php - - - - message: '#^Access to an undefined property App\\Models\\Deal\:\:\$phone\.$#' - identifier: property.notFound - count: 2 - path: tests/Feature/ProcessWebhookJobTest.php - - - - message: '#^Access to an undefined property App\\Models\\Deal\:\:\$received_at\.$#' - identifier: property.notFound - count: 2 - path: tests/Feature/ProcessWebhookJobTest.php - - - - message: '#^Access to an undefined property App\\Models\\Deal\:\:\$source_crm_id\.$#' - identifier: property.notFound - count: 1 - path: tests/Feature/ProcessWebhookJobTest.php - - - - message: '#^Access to an undefined property App\\Models\\Deal\:\:\$status\.$#' - identifier: property.notFound - count: 1 - path: tests/Feature/ProcessWebhookJobTest.php - - - - message: '#^Access to an undefined property App\\Models\\Project\:\:\$name\.$#' - identifier: property.notFound - count: 1 - path: tests/Feature/ProcessWebhookJobTest.php - - - - message: '#^Access to an undefined property App\\Models\\RejectedDealsLog\:\:\$payload\.$#' - identifier: property.notFound - count: 1 - path: tests/Feature/ProcessWebhookJobTest.php - - - - message: '#^Access to an undefined property App\\Models\\RejectedDealsLog\:\:\$reason\.$#' - identifier: property.notFound - count: 1 - path: tests/Feature/ProcessWebhookJobTest.php - - - - message: '#^Access to an undefined property App\\Models\\SupplierLeadCost\:\:\$cost_rub\.$#' - identifier: property.notFound - count: 1 - path: tests/Feature/ProcessWebhookJobTest.php - - - - message: '#^Access to an undefined property App\\Models\\SupplierLeadCost\:\:\$supplier_id\.$#' - identifier: property.notFound - count: 1 - path: tests/Feature/ProcessWebhookJobTest.php - - - - message: '#^Access to an undefined property App\\Models\\SupplierLeadCost\:\:\$supplier_lead_id\.$#' - identifier: property.notFound - count: 1 - path: tests/Feature/ProcessWebhookJobTest.php - - - - message: '#^Access to an undefined property App\\Models\\Tenant\:\:\$balance_leads\.$#' - identifier: property.notFound - count: 10 - path: tests/Feature/ProcessWebhookJobTest.php - - - - message: '#^Access to an undefined property App\\Models\\WebhookDedupKey\:\:\$deal_id\.$#' - identifier: property.notFound - count: 1 - path: tests/Feature/ProcessWebhookJobTest.php - - message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$project1Id\.$#' identifier: property.notFound @@ -1308,84 +624,6 @@ parameters: count: 3 path: tests/Feature/SetTenantContextTest.php - - - message: '#^Access to an undefined property App\\Models\\Project\:\:\$assignment_strategy\.$#' - identifier: property.notFound - count: 1 - path: tests/Feature/TenantModelsTest.php - - - - message: '#^Access to an undefined property App\\Models\\Project\:\:\$delivery_days_mask\.$#' - identifier: property.notFound - count: 1 - path: tests/Feature/TenantModelsTest.php - - - - message: '#^Access to an undefined property App\\Models\\Project\:\:\$region_mask\.$#' - identifier: property.notFound - count: 1 - path: tests/Feature/TenantModelsTest.php - - - - message: '#^Access to an undefined property App\\Models\\Project\:\:\$tenant_id\.$#' - identifier: property.notFound - count: 1 - path: tests/Feature/TenantModelsTest.php - - - - message: '#^Access to an undefined property App\\Models\\Project\:\:\$type\.$#' - identifier: property.notFound - count: 1 - path: tests/Feature/TenantModelsTest.php - - - - message: '#^Access to an undefined property App\\Models\\Tenant\:\:\$api_key_limit\.$#' - identifier: property.notFound - count: 1 - path: tests/Feature/TenantModelsTest.php - - - - message: '#^Access to an undefined property App\\Models\\Tenant\:\:\$is_trial\.$#' - identifier: property.notFound - count: 1 - path: tests/Feature/TenantModelsTest.php - - - - message: '#^Access to an undefined property App\\Models\\Tenant\:\:\$subdomain\.$#' - identifier: property.notFound - count: 1 - path: tests/Feature/TenantModelsTest.php - - - - message: '#^Access to an undefined property App\\Models\\User\:\:\$email\.$#' - identifier: property.notFound - count: 1 - path: tests/Feature/TenantModelsTest.php - - - - message: '#^Access to an undefined property App\\Models\\User\:\:\$is_active\.$#' - identifier: property.notFound - count: 1 - path: tests/Feature/TenantModelsTest.php - - - - message: '#^Access to an undefined property App\\Models\\User\:\:\$password_hash\.$#' - identifier: property.notFound - count: 2 - path: tests/Feature/TenantModelsTest.php - - - - message: '#^Access to an undefined property App\\Models\\User\:\:\$tenant_id\.$#' - identifier: property.notFound - count: 1 - path: tests/Feature/TenantModelsTest.php - - - - message: '#^Access to an undefined property App\\Models\\Tenant\:\:\$webhook_token\.$#' - identifier: property.notFound - count: 1 - path: tests/Feature/WebhookReceiveTest.php - - message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#' identifier: property.notFound diff --git a/app/resources/views/emails/new_lead.blade.php b/app/resources/views/emails/new_lead.blade.php new file mode 100644 index 00000000..75401d01 --- /dev/null +++ b/app/resources/views/emails/new_lead.blade.php @@ -0,0 +1,45 @@ + + + + + Новый лид — {{ $projectName }} + + +

Лидерра. Новый лид

+ +

Здравствуйте, {{ $manager->first_name ?? $manager->email }}.

+ +

В проекте {{ $projectName }} поступил новый лид.

+ + + + + + + @if ($deal->contact_name) + + + + + @endif + + + + + + + + +
Телефон + {{ $deal->phone }} +
Имя{{ $deal->contact_name }}
Получено{{ $deal->received_at?->setTimezone($manager->timezone ?? 'Europe/Moscow')->format('d.m.Y H:i') }}
ID сделки#{{ $deal->id }}
+ +

+ Откройте CRM, чтобы взять лид в работу. +

+ +

+ Это автоматическое уведомление о событии «Новый лид». Чтобы изменить настройки уведомлений — перейдите в Настройки → Уведомления. +

+ + diff --git a/app/tests/Feature/Notifications/NewLeadNotificationTest.php b/app/tests/Feature/Notifications/NewLeadNotificationTest.php new file mode 100644 index 00000000..f2ae67c4 --- /dev/null +++ b/app/tests/Feature/Notifications/NewLeadNotificationTest.php @@ -0,0 +1,198 @@ + + */ +function newLeadPayload(int $vid = 200, ?int $time = null): array +{ + return [ + 'vid' => $vid, + 'project' => 'B2_Caranga', + 'tag' => 'Caranga', + 'phone' => '79001234567', + 'phones' => ['79001234567'], + 'time' => $time ?? time(), + ]; +} + +/** + * @param array $newLeadPrefs + */ +function makeUserWithPrefs(Tenant $tenant, string $email, array $newLeadPrefs): User +{ + return User::factory()->create([ + 'tenant_id' => $tenant->id, + 'email' => $email, + 'notification_preferences' => [ + 'new_lead' => $newLeadPrefs, + 'reminder' => ['inapp' => true, 'push' => true, 'email' => true], + 'low_balance' => ['email' => true], + 'zero_balance' => ['email' => true], + 'topup_success' => ['email' => true], + 'invoice_paid' => ['email' => true], + 'new_device_login' => ['email' => true], + 'marketing' => ['email' => false], + ], + ]); +} + +test('webhook: NewLeadNotification отправляется user\'ам с email=true', function () { + $tenant = Tenant::factory()->create(['balance_leads' => 10]); + $userOn = makeUserWithPrefs($tenant, 'on@example.ru', ['inapp' => true, 'push' => true, 'email' => true]); + + (new ProcessWebhookJob($tenant->id, newLeadPayload()))->handle(); + + Mail::assertSent(NewLeadNotification::class, 1); + Mail::assertSent(NewLeadNotification::class, function (NewLeadNotification $mail) use ($userOn): bool { + return $mail->manager->id === $userOn->id + && $mail->hasTo('on@example.ru'); + }); +}); + +test('webhook: user с email=false НЕ получает', function () { + $tenant = Tenant::factory()->create(['balance_leads' => 10]); + makeUserWithPrefs($tenant, 'off@example.ru', ['inapp' => true, 'push' => true, 'email' => false]); + + (new ProcessWebhookJob($tenant->id, newLeadPayload()))->handle(); + + Mail::assertNothingSent(); +}); + +test('webhook: schema-default не шлёт (new_lead.email=false по дефолту)', function () { + $tenant = Tenant::factory()->create(['balance_leads' => 10]); + // Не передаём notification_preferences — берётся schema DEFAULT. + User::factory()->create([ + 'tenant_id' => $tenant->id, + 'email' => 'default@example.ru', + ]); + + (new ProcessWebhookJob($tenant->id, newLeadPayload()))->handle(); + + Mail::assertNothingSent(); +}); + +test('webhook: рассылается всем активным user\'ам с email=true', function () { + $tenant = Tenant::factory()->create(['balance_leads' => 10]); + makeUserWithPrefs($tenant, 'a@example.ru', ['email' => true]); + makeUserWithPrefs($tenant, 'b@example.ru', ['email' => true]); + makeUserWithPrefs($tenant, 'c@example.ru', ['email' => false]); + + (new ProcessWebhookJob($tenant->id, newLeadPayload()))->handle(); + + Mail::assertSent(NewLeadNotification::class, 2); + Mail::assertSent(fn (NewLeadNotification $mail) => $mail->hasTo('a@example.ru')); + Mail::assertSent(fn (NewLeadNotification $mail) => $mail->hasTo('b@example.ru')); + Mail::assertNotSent(fn (NewLeadNotification $mail) => $mail->hasTo('c@example.ru')); +}); + +test('webhook: inactive user с email=true НЕ получает (is_active=false)', function () { + $tenant = Tenant::factory()->create(['balance_leads' => 10]); + User::factory()->inactive()->create([ + 'tenant_id' => $tenant->id, + 'email' => 'inactive@example.ru', + 'notification_preferences' => [ + 'new_lead' => ['email' => true], + ], + ]); + + (new ProcessWebhookJob($tenant->id, newLeadPayload()))->handle(); + + Mail::assertNothingSent(); +}); + +test('webhook: soft-deleted user НЕ получает', function () { + $tenant = Tenant::factory()->create(['balance_leads' => 10]); + $user = makeUserWithPrefs($tenant, 'deleted@example.ru', ['email' => true]); + $user->delete(); + + (new ProcessWebhookJob($tenant->id, newLeadPayload()))->handle(); + + Mail::assertNothingSent(); +}); + +test('webhook: user другого тенанта НЕ получает (изоляция)', function () { + $tenantA = Tenant::factory()->create(['balance_leads' => 10]); + $tenantB = Tenant::factory()->create(['balance_leads' => 10]); + makeUserWithPrefs($tenantA, 'a@example.ru', ['email' => true]); + makeUserWithPrefs($tenantB, 'b@example.ru', ['email' => true]); + + (new ProcessWebhookJob($tenantA->id, newLeadPayload()))->handle(); + + Mail::assertSent(NewLeadNotification::class, 1); + Mail::assertSent(fn (NewLeadNotification $mail) => $mail->hasTo('a@example.ru')); + Mail::assertNotSent(fn (NewLeadNotification $mail) => $mail->hasTo('b@example.ru')); +}); + +test('webhook: дубль-сделка (Биз-19) НЕ шлёт повторное уведомление', function () { + $tenant = Tenant::factory()->create(['balance_leads' => 10]); + makeUserWithPrefs($tenant, 'on@example.ru', ['email' => true]); + + // Первая сделка — master. + (new ProcessWebhookJob($tenant->id, newLeadPayload(vid: 1)))->handle(); + Mail::assertSent(NewLeadNotification::class, 1); + + // Вторая сделка с тем же phone в окне 24 ч — дубль, баланс НЕ списывается, + // chargeNewLead НЕ вызывается, уведомление НЕ шлётся. + (new ProcessWebhookJob($tenant->id, newLeadPayload(vid: 2)))->handle(); + Mail::assertSent(NewLeadNotification::class, 1); // всё ещё одно +}); + +test('webhook: повторный vid (idempotent UPDATE) НЕ шлёт повторное уведомление', function () { + $tenant = Tenant::factory()->create(['balance_leads' => 10]); + makeUserWithPrefs($tenant, 'on@example.ru', ['email' => true]); + + (new ProcessWebhookJob($tenant->id, newLeadPayload(vid: 100)))->handle(); + Mail::assertSent(NewLeadNotification::class, 1); + + // Повторный webhook с тем же vid — UPDATE, не INSERT. wasRecentlyCreated=false → return. + (new ProcessWebhookJob($tenant->id, newLeadPayload(vid: 100)))->handle(); + Mail::assertSent(NewLeadNotification::class, 1); +}); + +test('webhook: balance=0 (RejectedDealsLog) НЕ шлёт уведомление', function () { + $tenant = Tenant::factory()->create(['balance_leads' => 0]); + makeUserWithPrefs($tenant, 'on@example.ru', ['email' => true]); + + (new ProcessWebhookJob($tenant->id, newLeadPayload()))->handle(); + + // chargeNewLead не вызывается при balance=0 — уведомление не отправляется. + Mail::assertNothingSent(); + expect(Deal::query()->where('tenant_id', $tenant->id)->count())->toBe(0); +}); + +test('NewLeadNotification: subject содержит project_name', function () { + $tenant = Tenant::factory()->create(['balance_leads' => 10]); + makeUserWithPrefs($tenant, 'on@example.ru', ['email' => true]); + + (new ProcessWebhookJob($tenant->id, newLeadPayload()))->handle(); + + Mail::assertSent(NewLeadNotification::class, function (NewLeadNotification $mail): bool { + return str_contains($mail->envelope()->subject, 'Caranga'); + }); +});