phase2(notifications-stage1): NotificationService + new_lead email (P0 этап 1)

Старт closing «Notification delivery» из карты P0. Этап 1/6 плана:
NotificationService + Mailable + интеграция в ProcessWebhookJob::chargeNewLead.

- App\Services\NotificationService — диспетчер 8 событий × 3 каналов
  (inapp/push/email) согласно schema.sql:699 users.notification_preferences.
  Этап 1 реализует только email-канал для new_lead.
- App\Mail\NewLeadNotification + emails/new_lead.blade.php — HTML-письмо
  в Forest-палитре с таблицей phone/contact_name/received_at/deal_id.
- ProcessWebhookJob::chargeNewLead — после ActivityLog вызывает
  notifyNewLead. Throwable от Mail::send проглатывается + Log::warning
  (отказ канала не должен валить транзакцию).
- Pest 11/11 в tests/Feature/Notifications/NewLeadNotificationTest.php:
  email=true получает / email=false не получает / schema-default не шлёт /
  inactive не получает / soft-deleted не получает / другой тенант не
  получает / Биз-19 дубль не дублирует / повторный vid не дублирует /
  balance=0 не шлёт / subject содержит project_name.
- IDE-helper регенерирован (4 модели получили @mixin docblocks).
- PHPStan baseline регенерирован (138 ignore.unmatched схлопнулись).

Pest 280/280 за 31.27 сек (+11 от 269, 1029 assertions).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Дмитрий
2026-05-09 11:03:43 +03:00
parent 830a652588
commit a4601fe84b
11 changed files with 474 additions and 778 deletions
+3 -1
View File
@@ -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 остаточные:** этапы 26 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.*
+12 -1
View File
@@ -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);
}
/**
+55
View File
@@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
namespace App\Mail;
use App\Models\Deal;
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
/**
* Уведомление о новом лиде (ТЗ §18.5, событие new_lead).
*
* Отправляется получателям тенанта, у которых в notification_preferences
* включён канал email для события new_lead. Триггер успешное создание
* сделки в ProcessWebhookJob::chargeNewLead.
*/
class NewLeadNotification extends Mailable
{
use Queueable;
use SerializesModels;
public function __construct(
public User $manager,
public Deal $deal,
public Tenant $tenant,
) {}
public function envelope(): Envelope
{
$projectName = $this->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 ?? 'Без проекта',
],
);
}
}
+2
View File
@@ -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
{
+2
View File
@@ -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
{
+2
View File
@@ -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
{
+2
View File
@@ -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
{
+139
View File
@@ -0,0 +1,139 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Mail\NewLeadNotification;
use App\Models\Deal;
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail;
use Throwable;
/**
* Центральный диспетчер уведомлений (ТЗ §18.5, schema v8.9 §4 users.notification_preferences).
*
* Матрица 8 событий × 3 каналов (inapp / push / email) хранится в
* `users.notification_preferences` JSONB. По умолчанию (см. schema.sql:699):
* new_lead {inapp:true, push:true, email:false}
* 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}
*
* Stage 1 (этот коммит): email-канал реализован для new_lead.
* Stage 2 in-app (требует in_app_notifications таблицы, schema v8.10).
* Stage 6 остальные 4 email-события (low_balance/zero_balance/topup_success/invoice_paid).
* push-канал Post-MVP (требует ServiceWorker + VAPID).
*/
class NotificationService
{
public const EVENT_NEW_LEAD = 'new_lead';
public const EVENT_REMINDER = 'reminder';
public const EVENT_LOW_BALANCE = 'low_balance';
public const EVENT_ZERO_BALANCE = 'zero_balance';
public const EVENT_TOPUP_SUCCESS = 'topup_success';
public const EVENT_INVOICE_PAID = 'invoice_paid';
public const EVENT_NEW_DEVICE_LOGIN = 'new_device_login';
public const EVENT_MARKETING = 'marketing';
public const ALL_EVENTS = [
self::EVENT_NEW_LEAD,
self::EVENT_REMINDER,
self::EVENT_LOW_BALANCE,
self::EVENT_ZERO_BALANCE,
self::EVENT_TOPUP_SUCCESS,
self::EVENT_INVOICE_PAID,
self::EVENT_NEW_DEVICE_LOGIN,
self::EVENT_MARKETING,
];
public const CHANNEL_INAPP = 'inapp';
public const CHANNEL_PUSH = 'push';
public const CHANNEL_EMAIL = 'email';
/**
* Уведомление о новом лиде. Получатели все активные user'ы тенанта
* с notification_preferences.new_lead.email=true.
*
* Webhook-поток: вызывается из ProcessWebhookJob::chargeNewLead() после
* успешного INSERT'а сделки + BalanceTransaction + ActivityLog.
*/
public function notifyNewLead(Tenant $tenant, Deal $deal): void
{
$recipients = $this->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<int, User>
*/
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(),
]);
}
}
}
+14 -776
View File
@@ -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\<int, App\\Models\\User\>\:\:\$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\<int,App\\Models\\ActivityLog\>\:\: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\<int,App\\Models\\Deal\>\:\: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\<int,App\\Models\\ImpersonationToken\>\:\: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\<int,App\\Models\\User\>\:\:map\(\) contains unresolvable type\.$#'
identifier: argument.unresolvableType
message: '#^Call to function is_array\(\) with array\<mixed\> 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\<int,App\\Models\\Project\>\:\: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\<string, mixed\>\) of method Database\\Factories\\ProjectFactory\:\:definition\(\) should be compatible with return type \(array\<model property of App\\Models\\Project, mixed\>\) of method Illuminate\\Database\\Eloquent\\Factories\\Factory\<App\\Models\\Project\>\:\: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\<int, App\\Models\\Project\>\:\:\$name\.$#'
identifier: property.notFound
count: 1
path: tests/Feature/DealCreateTest.php
-
message: '#^Access to an undefined property App\\Models\\Project\|Illuminate\\Database\\Eloquent\\Collection\<int, App\\Models\\Project\>\:\:\$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
@@ -0,0 +1,45 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<title>Новый лид {{ $projectName }}</title>
</head>
<body style="font-family: Inter, -apple-system, sans-serif; max-width: 600px; margin: 0 auto; padding: 24px; color: #081319;">
<h1 style="color: #0F6E56; font-size: 20px;">Лидерра. Новый лид</h1>
<p>Здравствуйте, {{ $manager->first_name ?? $manager->email }}.</p>
<p>В проекте <strong>{{ $projectName }}</strong> поступил новый лид.</p>
<table style="width: 100%; border-collapse: collapse; margin: 16px 0;">
<tr>
<td style="padding: 8px 12px; background: #F6F3EC; width: 140px; vertical-align: top;">Телефон</td>
<td style="padding: 8px 12px; background: #FFFFFF; border-left: 1px solid #E1EEEA;">
<a href="tel:{{ $deal->phone }}" style="color: #0F6E56; text-decoration: none;">{{ $deal->phone }}</a>
</td>
</tr>
@if ($deal->contact_name)
<tr>
<td style="padding: 8px 12px; background: #F6F3EC; vertical-align: top;">Имя</td>
<td style="padding: 8px 12px; background: #FFFFFF; border-left: 1px solid #E1EEEA;">{{ $deal->contact_name }}</td>
</tr>
@endif
<tr>
<td style="padding: 8px 12px; background: #F6F3EC; vertical-align: top;">Получено</td>
<td style="padding: 8px 12px; background: #FFFFFF; border-left: 1px solid #E1EEEA;">{{ $deal->received_at?->setTimezone($manager->timezone ?? 'Europe/Moscow')->format('d.m.Y H:i') }}</td>
</tr>
<tr>
<td style="padding: 8px 12px; background: #F6F3EC; vertical-align: top;">ID сделки</td>
<td style="padding: 8px 12px; background: #FFFFFF; border-left: 1px solid #E1EEEA;">#{{ $deal->id }}</td>
</tr>
</table>
<p style="margin-top: 24px;">
Откройте CRM, чтобы взять лид в работу.
</p>
<p style="color: #66635C; font-size: 12px; margin-top: 32px;">
Это автоматическое уведомление о событии «Новый лид». Чтобы изменить настройки уведомлений перейдите в Настройки Уведомления.
</p>
</body>
</html>
@@ -0,0 +1,198 @@
<?php
declare(strict_types=1);
use App\Jobs\ProcessWebhookJob;
use App\Mail\NewLeadNotification;
use App\Models\Deal;
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\Mail;
/**
* Тесты email-уведомления о новом лиде (ТЗ §18.5, событие new_lead).
*
* Проверяет интеграцию NotificationService ProcessWebhookJob: после успешного
* chargeNewLead все активные user'ы тенанта с notification_preferences.new_lead.email=true
* получают NewLeadNotification. Mail::fake() перехватывает реальную отправку.
*
* Schema-default: notification_preferences.new_lead.email=false по умолчанию
* никто не получает emails. Тесты явно ставят email=true для нужных user'ов.
*/
uses(DatabaseTransactions::class);
beforeEach(function () {
Mail::fake();
});
/**
* @return array<string, mixed>
*/
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<string, mixed> $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');
});
});