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
@@ -0,0 +1,88 @@
<?php
namespace App\Console\Commands;
use App\Models\Central\Company;
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\Console\Command;
use Illuminate\Support\Carbon;
class SendServiceRemindersCommand extends Command
{
protected $signature = 'reminders:send
{--slug= : Only one tenant by slug}
{--dry-run : Show candidates without sending}';
protected $description = 'Scan vehicles for due service reminders and send via configured channels.';
public function handle(NotificationDispatcher $dispatcher): int
{
$query = Company::query()->where('status', '!=', 'archived');
if ($slug = $this->option('slug')) {
$query->where('slug', $slug);
}
$companies = $query->get();
$dry = (bool) $this->option('dry-run');
$totalSent = 0;
foreach ($companies as $company) {
app(TenantManager::class)->setCurrent($company);
$settings = (array) ($company->settings ?? []);
$reminderDays = (int) data_get($settings, 'reminder.after_days', 365);
$cooldownDays = (int) data_get($settings, 'reminder.cooldown_days', 30);
$cutoff = Carbon::now()->subDays($reminderDays);
$cooldown = Carbon::now()->subDays($cooldownDays);
// Pick vehicles whose last *closed* WO was before $cutoff (or never).
$vehicles = Vehicle::with('client')
->whereHas('client', fn ($q) => $q->where('status', 'active'))
->get();
$sentThisTenant = 0;
foreach ($vehicles as $v) {
$lastClosedAt = WorkOrder::where('vehicle_id', $v->id)
->whereNotNull('closed_at')
->max('closed_at');
if (! $lastClosedAt) continue; // never serviced — skip (handled by other logic)
if (Carbon::parse($lastClosedAt)->gt($cutoff)) continue;
$recent = ServiceReminderSent::where('vehicle_id', $v->id)
->where('sent_at', '>=', $cooldown)
->exists();
if ($recent) continue;
if ($dry) {
$this->line(" - [{$company->slug}] Vehicle #{$v->id} {$v->plate} last serviced {$lastClosedAt}");
continue;
}
$ok = $dispatcher->serviceReminder($v, 'general');
if ($ok) {
ServiceReminderSent::create([
'company_id' => $company->id,
'vehicle_id' => $v->id,
'client_id' => $v->client_id,
'channel' => $v->client?->telegram_chat_id ? 'telegram' : 'email',
'type' => 'general',
'sent_at' => now(),
]);
$sentThisTenant++;
}
}
$this->info(sprintf('[%s] reminders sent: %d', $company->slug, $sentThisTenant));
$totalSent += $sentThisTenant;
}
$this->info("Total reminders sent: {$totalSent}" . ($dry ? ' (dry run)' : ''));
return self::SUCCESS;
}
}