Stage 13 — Notifications: Telegram bot + multi-channel + service reminders

Schema:
- clients.telegram_chat_id (linked via /start contact-share)
- clients.notify_prefs (per-client channel order override)
- service_reminders_sent (dedup ledger for the daily cron)

Telegram (per tenant):
- TelegramService (sendMessage, getMe, setWebhook with auto-generated secret)
- Bot token stored in companies.settings.telegram.bot_token
- Webhook /telegram/webhook/{slug} validates X-Telegram-Bot-Api-Secret-Token,
  matches client by last 9 digits of phone, persists chat_id, replies confirm
- /start prompts share-contact; /stop unlinks chat_id

NotificationDispatcher refactor:
- Multi-channel: telegram first if chat_id + bot configured, then email
- Backwards-compat with legacy boolean notify.{type} flags
- 4 HTML-formatted Telegram messages (wo_ready with tracking link, payment,
  appointment, reminder)

Service reminders:
- `reminders:send` artisan command with --slug / --dry-run
- Policy: vehicles whose last closed WO is older than reminder.after_days
  (default 365). Skips if sent within reminder.cooldown_days (default 30).
- Schedule daily 09:00

Filament UI:
- Settings page: Telegram bot token field + "Test bot" + "Set webhook" actions
- Settings page: reminder_after_days + reminder_cooldown_days inputs
- ClientResource: telegram_chat_id readonly badge

Tests (6 new, all pass):
- webhook links client via shared contact
- webhook rejects wrong secret → 401
- dispatcher uses telegram when chat_id present (Http::fake)
- dispatcher falls back to email otherwise
- dispatcher returns false when no channel available
- reminder cron respects 30-day cooldown

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-27 20:14:17 +00:00
parent a2026f640a
commit 85ef2f6e00
12 changed files with 856 additions and 53 deletions
+192
View File
@@ -0,0 +1,192 @@
<?php
namespace Tests\Feature;
use App\Models\Central\Company;
use App\Models\Central\Plan;
use App\Models\Tenant\Client;
use App\Models\Tenant\ServiceReminderSent;
use App\Models\Tenant\Vehicle;
use App\Models\Tenant\WorkOrder;
use App\Services\NotificationDispatcher;
use App\Tenancy\TenantManager;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
use Tests\TestCase;
class TelegramNotificationsTest extends TestCase
{
use RefreshDatabase;
public function test_webhook_links_client_via_shared_phone(): void
{
$ctx = $this->makeContext(withBot: true);
$payload = [
'message' => [
'chat' => ['id' => '555111222'],
'contact' => ['phone_number' => $ctx['client']->phone],
'text' => '',
],
];
Http::fake(['*' => Http::response(['ok' => true])]);
$resp = $this->withHeaders([
'X-Telegram-Bot-Api-Secret-Token' => 'secret-' . $ctx['company']->slug,
])->postJson("/telegram/webhook/{$ctx['company']->slug}", $payload);
$resp->assertOk();
$ctx['client']->refresh();
$this->assertEquals('555111222', $ctx['client']->telegram_chat_id);
}
public function test_webhook_rejects_wrong_secret(): void
{
$ctx = $this->makeContext(withBot: true);
$resp = $this->withHeaders(['X-Telegram-Bot-Api-Secret-Token' => 'wrong'])
->postJson("/telegram/webhook/{$ctx['company']->slug}", ['message' => []]);
$resp->assertStatus(401);
}
public function test_dispatcher_uses_telegram_when_chat_id_present(): void
{
$ctx = $this->makeContext(withBot: true);
$ctx['client']->telegram_chat_id = '999';
$ctx['client']->saveQuietly();
Http::fake([
'api.telegram.org/*' => Http::response(['ok' => true, 'result' => []]),
]);
$wo = $this->makeWorkOrder($ctx);
$ok = app(NotificationDispatcher::class)->workOrderReady($wo);
$this->assertTrue($ok);
Http::assertSent(fn ($req) => str_contains($req->url(), 'sendMessage'));
}
public function test_dispatcher_falls_back_to_email_when_no_chat_id(): void
{
$ctx = $this->makeContext(withBot: true);
// No chat_id set on client.
\Illuminate\Support\Facades\Mail::fake();
Http::fake();
$wo = $this->makeWorkOrder($ctx);
$ok = app(NotificationDispatcher::class)->workOrderReady($wo);
$this->assertTrue($ok);
Http::assertNothingSent();
\Illuminate\Support\Facades\Mail::assertSent(\App\Mail\WorkOrderReadyMail::class);
}
public function test_dispatcher_returns_false_when_all_channels_disabled(): void
{
$ctx = $this->makeContext(withBot: true);
$ctx['client']->email = null;
$ctx['client']->saveQuietly();
$wo = $this->makeWorkOrder($ctx);
$ok = app(NotificationDispatcher::class)->workOrderReady($wo);
$this->assertFalse($ok);
}
public function test_reminder_cron_respects_cooldown(): void
{
$ctx = $this->makeContext();
// Set settings: after_days=1 (anything older than 1 day triggers)
$ctx['company']->update(['settings' => array_merge((array) $ctx['company']->settings, [
'reminder' => ['after_days' => 1, 'cooldown_days' => 30],
])]);
$client = $ctx['client'];
$vehicle = Vehicle::create([
'client_id' => $client->id,
'make' => 'BMW', 'model' => 'X5',
'plate' => 'REM-1',
]);
// Closed WO 5 days ago.
WorkOrder::create([
'number' => WorkOrder::generateNumber($ctx['company']->id),
'client_id' => $client->id,
'vehicle_id' => $vehicle->id,
'opened_at' => now()->subDays(10),
'closed_at' => now()->subDays(5),
'status' => 'done',
]);
// Already sent within cooldown.
ServiceReminderSent::create([
'company_id' => $ctx['company']->id,
'vehicle_id' => $vehicle->id,
'client_id' => $client->id,
'channel' => 'email',
'type' => 'general',
'sent_at' => now()->subDays(5),
]);
\Illuminate\Support\Facades\Mail::fake();
$this->artisan('reminders:send', ['--slug' => $ctx['company']->slug])
->assertSuccessful();
\Illuminate\Support\Facades\Mail::assertNothingSent();
}
private function makeContext(bool $withBot = false): array
{
$plan = Plan::firstOrCreate(['slug' => 'test'], [
'name' => 'Test', 'price' => 0, 'features' => [],
]);
$slug = 'tg-' . uniqid();
$settings = [];
if ($withBot) {
$settings['telegram'] = [
'bot_token' => 'FAKE:TOKEN',
'webhook_secret' => "secret-{$slug}",
];
}
$company = Company::create([
'plan_id' => $plan->id,
'slug' => $slug,
'name' => 'TG Service',
'status' => 'active',
'settings' => $settings,
]);
app(TenantManager::class)->setCurrent($company);
$client = Client::create([
'name' => 'Tester',
'phone' => '+37377' . random_int(100000, 999999),
'email' => 'tester@example.com',
'type' => 'individual',
'status' => 'active',
]);
return compact('company', 'client');
}
private function makeWorkOrder(array $ctx): WorkOrder
{
$vehicle = Vehicle::create([
'client_id' => $ctx['client']->id,
'make' => 'Audi', 'model' => 'A4',
'plate' => 'TG-' . random_int(100, 999),
]);
$wo = WorkOrder::create([
'number' => WorkOrder::generateNumber($ctx['company']->id),
'client_id' => $ctx['client']->id,
'vehicle_id' => $vehicle->id,
'opened_at' => now(),
'status' => 'ready',
'total' => 250.00,
]);
return $wo;
}
}