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:
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user