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:
@@ -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.*
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 ?? 'Без проекта',
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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
@@ -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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user