From 85ef2f6e003bcaf2849c38828acf56d9c8039385 Mon Sep 17 00:00:00 2001 From: Vasyka Date: Wed, 27 May 2026 20:14:17 +0000 Subject: [PATCH] =?UTF-8?q?Stage=2013=20=E2=80=94=20Notifications:=20Teleg?= =?UTF-8?q?ram=20bot=20+=20multi-channel=20+=20service=20reminders?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../Commands/SendServiceRemindersCommand.php | 88 +++++++ app/Filament/Tenant/Pages/Settings.php | 91 ++++++- .../Tenant/Resources/ClientResource.php | 8 + .../Controllers/TelegramWebhookController.php | 100 ++++++++ app/Models/Tenant/Client.php | 12 +- app/Models/Tenant/ServiceReminderSent.php | 33 +++ app/Services/NotificationDispatcher.php | 227 ++++++++++++++---- .../Notifications/TelegramService.php | 103 ++++++++ ...05_27_180000_add_notification_channels.php | 39 +++ routes/console.php | 6 + routes/web.php | 10 + tests/Feature/TelegramNotificationsTest.php | 192 +++++++++++++++ 12 files changed, 856 insertions(+), 53 deletions(-) create mode 100644 app/Console/Commands/SendServiceRemindersCommand.php create mode 100644 app/Http/Controllers/TelegramWebhookController.php create mode 100644 app/Models/Tenant/ServiceReminderSent.php create mode 100644 app/Services/Notifications/TelegramService.php create mode 100644 database/migrations/2026_05_27_180000_add_notification_channels.php create mode 100644 tests/Feature/TelegramNotificationsTest.php diff --git a/app/Console/Commands/SendServiceRemindersCommand.php b/app/Console/Commands/SendServiceRemindersCommand.php new file mode 100644 index 0000000..b50f2ee --- /dev/null +++ b/app/Console/Commands/SendServiceRemindersCommand.php @@ -0,0 +1,88 @@ +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; + } +} diff --git a/app/Filament/Tenant/Pages/Settings.php b/app/Filament/Tenant/Pages/Settings.php index 8f89e7b..b1cf73b 100644 --- a/app/Filament/Tenant/Pages/Settings.php +++ b/app/Filament/Tenant/Pages/Settings.php @@ -2,7 +2,9 @@ namespace App\Filament\Tenant\Pages; +use App\Services\Notifications\TelegramService; use App\Tenancy\TenantManager; +use Filament\Actions; use Filament\Forms; use Filament\Notifications\Notification; use Filament\Pages\Page; @@ -50,6 +52,9 @@ class Settings extends Page 'notify_payment' => $notify['payment'] ?? true, 'notify_appointment' => $notify['appointment'] ?? true, 'notify_reminder' => $notify['reminder'] ?? true, + 'telegram_bot_token' => data_get($settings, 'telegram.bot_token'), + 'reminder_after_days' => data_get($settings, 'reminder.after_days', 365), + 'reminder_cooldown_days' => data_get($settings, 'reminder.cooldown_days', 30), 'ai_default_provider' => $settings['ai']['default_provider'] ?? 'claude', 'ai_claude_key' => $settings['ai']['claude_key'] ?? null, 'ai_gpt_key' => $settings['ai']['gpt_key'] ?? null, @@ -126,8 +131,8 @@ class Settings extends Page ->maxSize(512) ->helperText('PNG/ICO, max 512 KB.'), ]), - Schemas\Components\Section::make('Notificări email') - ->description('Activează / dezactivează emailurile auto către clienți.') + Schemas\Components\Section::make('Notificări') + ->description('Activează / dezactivează notificările auto către clienți. Telegram are prioritate dacă clientul are cont legat.') ->columns(2) ->schema([ Forms\Components\Toggle::make('notify_wo_ready')->label('Mașina e gata de ridicat')->default(true), @@ -135,6 +140,33 @@ class Settings extends Page Forms\Components\Toggle::make('notify_appointment')->label('Programare confirmată')->default(true), Forms\Components\Toggle::make('notify_reminder')->label('Reminder ITP / revizie')->default(true), ]), + Schemas\Components\Section::make('Telegram bot') + ->description('Creează un bot la @BotFather, lipește token-ul aici și apasă „Setează webhook". Clienții îți scriu la bot, partajează telefonul, iar codul se leagă automat de fișa lor.') + ->columns(1) + ->schema([ + Forms\Components\TextInput::make('telegram_bot_token') + ->label('Bot token') + ->password() + ->revealable() + ->placeholder('123456:ABC-XYZ...') + ->helperText(fn () => 'Webhook URL: ' . + app(\App\Services\Notifications\TelegramService::class) + ->webhookUrlFor(app(\App\Tenancy\TenantManager::class)->current())), + ]), + Schemas\Components\Section::make('Reminder service auto') + ->columns(2) + ->schema([ + Forms\Components\TextInput::make('reminder_after_days') + ->label('Trimite reminder după X zile fără vizită') + ->numeric() + ->minValue(30) + ->default(365), + Forms\Components\TextInput::make('reminder_cooldown_days') + ->label('Nu re-trimite mai des de X zile') + ->numeric() + ->minValue(7) + ->default(30), + ]), Schemas\Components\Section::make('Asistent AI') ->description('Adaugă chei API ca să activezi asistentul. Cheile rămân la voi — nu sunt partajate.') ->columns(2) @@ -178,6 +210,14 @@ class Settings extends Page 'appointment' => (bool) ($data['notify_appointment'] ?? true), 'reminder' => (bool) ($data['notify_reminder'] ?? true), ], + 'telegram' => array_replace( + (array) data_get($company->settings, 'telegram', []), + ['bot_token' => $data['telegram_bot_token'] ?? null] + ), + 'reminder' => [ + 'after_days' => (int) ($data['reminder_after_days'] ?? 365), + 'cooldown_days' => (int) ($data['reminder_cooldown_days'] ?? 30), + ], 'ai' => [ 'default_provider' => $data['ai_default_provider'] ?? 'claude', 'claude_key' => $data['ai_claude_key'] ?? null, @@ -201,4 +241,51 @@ class Settings extends Page Notification::make()->title('Setări salvate')->success()->send(); } + + protected function getHeaderActions(): array + { + return [ + Actions\Action::make('telegram_test') + ->label('Testează bot Telegram') + ->icon('heroicon-m-bolt') + ->color('gray') + ->action(function () { + $company = app(TenantManager::class)->current(); + if (! $company) return; + $r = app(TelegramService::class)->getMe($company); + if (! ($r['ok'] ?? false)) { + Notification::make() + ->title('Bot Telegram nu răspunde') + ->body($r['error'] ?? 'Verifică token-ul.') + ->danger()->send(); + return; + } + $name = data_get($r, 'response.result.username', '?'); + Notification::make() + ->title("Bot OK: @{$name}") + ->success()->send(); + }), + Actions\Action::make('telegram_webhook') + ->label('Setează webhook') + ->icon('heroicon-m-link') + ->color('primary') + ->requiresConfirmation() + ->modalDescription('Telegram va trimite mesajele primite la URL-ul webhook de mai jos.') + ->action(function () { + $company = app(TenantManager::class)->current(); + if (! $company) return; + $r = app(TelegramService::class)->setWebhook($company); + if (! ($r['ok'] ?? false)) { + Notification::make() + ->title('Webhook eșuat') + ->body($r['error'] ?? json_encode($r['response'] ?? [])) + ->danger()->send(); + return; + } + Notification::make() + ->title('Webhook setat — botul e gata') + ->success()->send(); + }), + ]; + } } diff --git a/app/Filament/Tenant/Resources/ClientResource.php b/app/Filament/Tenant/Resources/ClientResource.php index 2477422..45ee540 100644 --- a/app/Filament/Tenant/Resources/ClientResource.php +++ b/app/Filament/Tenant/Resources/ClientResource.php @@ -73,6 +73,14 @@ class ClientResource extends Resource Forms\Components\TextInput::make('phone_alt')->label('Telefon alternativ')->tel()->maxLength(40), Forms\Components\TextInput::make('email')->email()->maxLength(120), Forms\Components\TextInput::make('telegram')->maxLength(60), + Forms\Components\TextInput::make('telegram_chat_id') + ->label('Telegram chat ID') + ->disabled() + ->dehydrated(false) + ->placeholder('Se completează automat când clientul scrie la bot') + ->helperText(fn ($record) => $record?->telegram_chat_id + ? '✅ Telegram legat — notificările vor merge prin bot' + : null), Forms\Components\TextInput::make('whatsapp')->maxLength(60), Forms\Components\TextInput::make('viber')->maxLength(60), ]), diff --git a/app/Http/Controllers/TelegramWebhookController.php b/app/Http/Controllers/TelegramWebhookController.php new file mode 100644 index 0000000..6a867c3 --- /dev/null +++ b/app/Http/Controllers/TelegramWebhookController.php @@ -0,0 +1,100 @@ +first(); + if (! $company) return response()->json(['ok' => false], 404); + + $expectedSecret = $telegram->webhookSecretFor($company); + $providedSecret = $request->header('X-Telegram-Bot-Api-Secret-Token'); + if ($expectedSecret && $providedSecret !== $expectedSecret) { + Log::warning('telegram.webhook bad secret', ['tenant' => $slug]); + return response()->json(['ok' => false], 401); + } + + app(TenantManager::class)->setCurrent($company); + + $message = $request->input('message', []); + $chatId = (string) data_get($message, 'chat.id', ''); + if (! $chatId) { + return response()->json(['ok' => true]); + } + + $contact = data_get($message, 'contact'); + $text = trim((string) data_get($message, 'text', '')); + + $client = null; + $phoneRaw = null; + + if ($contact) { + $phoneRaw = data_get($contact, 'phone_number'); + } elseif (preg_match('/(\+?[0-9\-\s\(\)]{7,})/', $text, $m)) { + $phoneRaw = $m[1]; + } + + if ($phoneRaw) { + $needle = Client::normalizePhone($phoneRaw); + if ($needle) { + $client = Client::whereRaw( + "REPLACE(REPLACE(REPLACE(REPLACE(phone, ' ', ''), '-', ''), '(', ''), ')', '') LIKE ?", + ['%' . substr($needle, -9) . '%'] + )->first(); + } + } + + if (! $client && $text === '/start') { + $telegram->sendMessage($company, $chatId, + 'Salut! Pentru a primi notificări despre mașina ta, ' . + 'apasă butonul „Share contact" sau trimite numărul tău de telefon.' + ); + return response()->json(['ok' => true]); + } + + if ($client) { + $client->telegram_chat_id = $chatId; + $client->saveQuietly(); + + $name = $company->display_name ?? $company->name; + $telegram->sendMessage($company, $chatId, + "Te-am identificat — {$client->name}.\n" . + "Vei primi aici notificări despre fișele tale de la {$name}.\n\n" . + "Trimite /stop oricând ca să oprești notificările." + ); + return response()->json(['ok' => true]); + } + + if ($text === '/stop') { + Client::where('telegram_chat_id', $chatId)->update(['telegram_chat_id' => null]); + $telegram->sendMessage($company, $chatId, 'Notificările au fost oprite.'); + return response()->json(['ok' => true]); + } + + if ($phoneRaw) { + $telegram->sendMessage($company, $chatId, + "Nu am găsit un client cu acest număr la {$company->name}. " . + "Verifică telefonul sau contactează service-ul." + ); + } + + return response()->json(['ok' => true]); + } +} diff --git a/app/Models/Tenant/Client.php b/app/Models/Tenant/Client.php index f7606f2..07e6267 100644 --- a/app/Models/Tenant/Client.php +++ b/app/Models/Tenant/Client.php @@ -16,7 +16,8 @@ class Client extends Model protected $fillable = [ 'company_id', 'type', 'name', 'company_name', 'phone', 'phone_alt', 'email', - 'telegram', 'whatsapp', 'viber', + 'telegram', 'telegram_chat_id', 'whatsapp', 'viber', + 'notify_prefs', 'source', 'marketing_channel', 'status', 'balance', 'discount_pct', 'notes', 'assigned_to', 'last_contact_at', @@ -26,8 +27,17 @@ class Client extends Model 'balance' => 'decimal:2', 'discount_pct' => 'decimal:2', 'last_contact_at' => 'datetime', + 'notify_prefs' => 'array', ]; + /** Normalize a phone number to E.164-ish digits for matching. */ + public static function normalizePhone(?string $phone): ?string + { + if (! $phone) return null; + $digits = preg_replace('/[^0-9]/', '', $phone); + return $digits ?: null; + } + public function vehicles(): HasMany { return $this->hasMany(Vehicle::class); diff --git a/app/Models/Tenant/ServiceReminderSent.php b/app/Models/Tenant/ServiceReminderSent.php new file mode 100644 index 0000000..2a92a70 --- /dev/null +++ b/app/Models/Tenant/ServiceReminderSent.php @@ -0,0 +1,33 @@ + 'datetime', + ]; + + public function vehicle(): BelongsTo + { + return $this->belongsTo(Vehicle::class); + } + + public function client(): BelongsTo + { + return $this->belongsTo(Client::class); + } +} diff --git a/app/Services/NotificationDispatcher.php b/app/Services/NotificationDispatcher.php index 8e79df9..ed6d971 100644 --- a/app/Services/NotificationDispatcher.php +++ b/app/Services/NotificationDispatcher.php @@ -8,85 +8,220 @@ use App\Mail\ServiceReminderMail; use App\Mail\WorkOrderReadyMail; use App\Models\Central\Company; use App\Models\Tenant\Appointment; +use App\Models\Tenant\Client; use App\Models\Tenant\Payment; use App\Models\Tenant\Vehicle; use App\Models\Tenant\WorkOrder; +use App\Services\Notifications\TelegramService; use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Mail; /** - * Centralizes outbound email notifications, with per-tenant feature toggles - * stored in companies.settings.notify (e.g., 'wo_ready' => true). + * Multi-channel outbound notifications with per-tenant + per-client opt-in/out. * - * Usage: - * app(NotificationDispatcher::class)->workOrderReady($workOrder); + * Channels tried in order: + * 1. Telegram — if client has telegram_chat_id AND tenant bot token AND + * tenant has notify.{type}.telegram = true (default true when bot configured) + * 2. Email — if client has email AND tenant has notify.{type}.email = true + * (default true — preserves legacy behaviour) + * + * Per-client overrides via clients.notify_prefs JSON (array of channel keys). + * + * Public methods return TRUE when at least one channel succeeded. */ class NotificationDispatcher { + public function __construct(private TelegramService $telegram) + { + } + public function workOrderReady(WorkOrder $wo): bool { $company = $this->companyFor($wo); - if (! $this->isEnabled($company, 'wo_ready')) return false; + $client = $wo->client; + if (! $client) return false; - $email = $wo->client?->email; - if (! $email) return false; - - try { - Mail::to($email)->send(new WorkOrderReadyMail($wo, $company)); - return true; - } catch (\Throwable $e) { - Log::warning('workOrderReady mail failed', ['wo' => $wo->id, 'err' => $e->getMessage()]); - return false; - } + return $this->dispatch($company, $client, 'wo_ready', [ + 'telegram' => fn () => $this->tgWorkOrderReady($wo, $company, $client), + 'email' => fn () => $this->emailSafe( + fn () => Mail::to($client->email)->send(new WorkOrderReadyMail($wo, $company)), + 'workOrderReady', ['wo' => $wo->id] + ), + ]); } public function paymentReceived(Payment $payment): bool { $company = $this->companyFor($payment); - if (! $this->isEnabled($company, 'payment')) return false; + $client = $payment->client; + if (! $client) return false; - $email = $payment->client?->email; - if (! $email) return false; - - try { - Mail::to($email)->send(new PaymentReceivedMail($payment, $company)); - return true; - } catch (\Throwable $e) { - Log::warning('paymentReceived mail failed', ['payment' => $payment->id, 'err' => $e->getMessage()]); - return false; - } + return $this->dispatch($company, $client, 'payment', [ + 'telegram' => fn () => $this->tgPaymentReceived($payment, $company, $client), + 'email' => fn () => $this->emailSafe( + fn () => Mail::to($client->email)->send(new PaymentReceivedMail($payment, $company)), + 'paymentReceived', ['payment' => $payment->id] + ), + ]); } public function appointmentConfirmed(Appointment $a): bool { $company = $this->companyFor($a); - if (! $this->isEnabled($company, 'appointment')) return false; + $client = $a->client; + if (! $client) return false; - $email = $a->client?->email; - if (! $email) return false; - - try { - Mail::to($email)->send(new AppointmentConfirmedMail($a, $company)); - return true; - } catch (\Throwable $e) { - Log::warning('appointmentConfirmed mail failed', ['appt' => $a->id, 'err' => $e->getMessage()]); - return false; - } + return $this->dispatch($company, $client, 'appointment', [ + 'telegram' => fn () => $this->tgAppointmentConfirmed($a, $company, $client), + 'email' => fn () => $this->emailSafe( + fn () => Mail::to($client->email)->send(new AppointmentConfirmedMail($a, $company)), + 'appointmentConfirmed', ['appt' => $a->id] + ), + ]); } public function serviceReminder(Vehicle $v, string $type = 'general', ?string $note = null): bool { $company = $this->companyFor($v); - if (! $this->isEnabled($company, 'reminder')) return false; + $client = $v->client; + if (! $client) return false; - $email = $v->client?->email; - if (! $email) return false; + return $this->dispatch($company, $client, 'reminder', [ + 'telegram' => fn () => $this->tgServiceReminder($v, $type, $note, $company, $client), + 'email' => fn () => $this->emailSafe( + fn () => Mail::to($client->email)->send(new ServiceReminderMail($v, $type, $note, $company)), + 'serviceReminder', ['vehicle' => $v->id] + ), + ]); + } + // ─── Channel dispatch ───────────────────────────────────────── + + /** + * @param array $senders channel-key → sender callback + * @return bool Returns the channel name that delivered, or null on full miss. + */ + protected function dispatch(Company $company, Client $client, string $key, array $senders): bool + { + $any = false; + foreach ($this->channelsFor($company, $client, $key) as $channel) { + if (! isset($senders[$channel])) continue; + try { + if (($senders[$channel])() === true) { + $any = true; + // Try only one channel — first that succeeds is enough. + break; + } + } catch (\Throwable $e) { + Log::warning("notify.{$key} {$channel} threw", ['err' => $e->getMessage()]); + } + } + return $any; + } + + /** + * Resolve which channels to try and in what order, applying per-client + * preference if set, otherwise the tenant default. + */ + protected function channelsFor(Company $company, Client $client, string $key): array + { + $prefs = (array) ($client->notify_prefs ?? []); + + // Per-client list takes precedence. + if (! empty($prefs)) { + $candidates = array_values(array_intersect(['telegram', 'email'], $prefs)); + } else { + $candidates = ['telegram', 'email']; + } + + return array_values(array_filter( + $candidates, + fn (string $ch) => $this->channelEnabled($company, $client, $key, $ch) + )); + } + + protected function channelEnabled(Company $company, Client $client, string $key, string $channel): bool + { + $notify = (array) data_get($company->settings, 'notify', []); + // Tenant-level: notify.{type}.{channel} — accept legacy boolean notify.{type}=true as email-only flag. + $tenantValue = $notify[$key] ?? null; + if (is_bool($tenantValue)) { + // legacy: email default true, telegram default true if bot configured + if ($channel === 'email') $tenantValueEnabled = $tenantValue; + elseif ($channel === 'telegram') $tenantValueEnabled = $tenantValue && (bool) $this->telegram->tokenFor($company); + else $tenantValueEnabled = false; + } elseif (is_array($tenantValue)) { + $tenantValueEnabled = (bool) ($tenantValue[$channel] ?? true); + } else { + $tenantValueEnabled = true; + } + + if (! $tenantValueEnabled) return false; + + // Per-client field presence + if ($channel === 'telegram') { + return ! empty($client->telegram_chat_id) && (bool) $this->telegram->tokenFor($company); + } + if ($channel === 'email') { + return ! empty($client->email); + } + return false; + } + + // ─── Telegram message builders ──────────────────────────────── + + protected function tgWorkOrderReady(WorkOrder $wo, Company $company, Client $client): bool + { + $brand = htmlspecialchars($company->display_name ?? $company->name); + $no = htmlspecialchars((string) $wo->number); + $plate = htmlspecialchars((string) ($wo->vehicle->plate ?? '')); + $text = "✅ Mașina e gata de ridicat\n" + . "Fișa #{$no} · {$plate}\n" + . "Total: " . number_format((float) $wo->total, 2) . " " . ($company->settings['currency'] ?? 'MDL') . "\n\n" + . "🔗 Detalii: " . $wo->trackingUrl() . "\n\n" + . "{$brand}"; + return $this->telegram->sendMessage($company, (string) $client->telegram_chat_id, $text); + } + + protected function tgPaymentReceived(Payment $payment, Company $company, Client $client): bool + { + $amt = number_format((float) $payment->amount, 2); + $cur = $company->settings['currency'] ?? 'MDL'; + $text = "💳 Plată primită\n" + . "Suma: {$amt} {$cur}\n" + . "Mulțumim!"; + return $this->telegram->sendMessage($company, (string) $client->telegram_chat_id, $text); + } + + protected function tgAppointmentConfirmed(Appointment $a, Company $company, Client $client): bool + { + $when = $a->starts_at?->isoFormat('D MMM YYYY, HH:mm') ?? '?'; + $text = "📅 Programare confirmată\n" + . "Data: {$when}\n" + . ($a->vehicle?->plate ? "Auto: " . htmlspecialchars($a->vehicle->plate) . "\n" : '') + . "Te așteptăm."; + return $this->telegram->sendMessage($company, (string) $client->telegram_chat_id, $text); + } + + protected function tgServiceReminder(Vehicle $v, string $type, ?string $note, Company $company, Client $client): bool + { + $brand = htmlspecialchars($company->display_name ?? $company->name); + $plate = htmlspecialchars((string) ($v->plate ?? '')); + $text = "🔧 Reminder service\n" + . "{$brand}: " . htmlspecialchars($v->make . ' ' . $v->model) . " · {$plate}\n" + . ($note ?: 'A trecut ceva timp de la ultima vizită — recomandăm o verificare.'); + return $this->telegram->sendMessage($company, (string) $client->telegram_chat_id, $text); + } + + // ─── Helpers ────────────────────────────────────────────────── + + protected function emailSafe(callable $fn, string $tag, array $ctx = []): bool + { try { - Mail::to($email)->send(new ServiceReminderMail($v, $type, $note, $company)); + $fn(); return true; } catch (\Throwable $e) { - Log::warning('serviceReminder mail failed', ['vehicle' => $v->id, 'err' => $e->getMessage()]); + Log::warning("{$tag} mail failed", $ctx + ['err' => $e->getMessage()]); return false; } } @@ -95,12 +230,4 @@ class NotificationDispatcher { return Company::withoutGlobalScopes()->findOrFail($model->company_id); } - - protected function isEnabled(Company $company, string $key): bool - { - $settings = (array) ($company->settings ?? []); - $notify = (array) ($settings['notify'] ?? []); - // default: enabled (toate notificările active by default) - return ($notify[$key] ?? true) === true; - } } diff --git a/app/Services/Notifications/TelegramService.php b/app/Services/Notifications/TelegramService.php new file mode 100644 index 0000000..16cf3cd --- /dev/null +++ b/app/Services/Notifications/TelegramService.php @@ -0,0 +1,103 @@ +settings, 'telegram.bot_token'); + } + + public function webhookSecretFor(Company $company): ?string + { + return data_get($company->settings, 'telegram.webhook_secret'); + } + + public function webhookUrlFor(Company $company): string + { + $central = config('app.central_domain') ?: 'service.mir.md'; + // We expose the webhook on the central domain so Telegram does not + // need to know about subdomain wildcards. Slug routes to tenant. + return "https://{$central}/telegram/webhook/{$company->slug}"; + } + + public function sendMessage(Company $company, string $chatId, string $text, array $options = []): bool + { + $token = $this->tokenFor($company); + if (! $token || ! $chatId) return false; + + try { + $resp = Http::asJson() + ->timeout(10) + ->post(self::API . $token . '/sendMessage', array_merge([ + 'chat_id' => $chatId, + 'text' => $text, + 'parse_mode' => 'HTML', + 'disable_web_page_preview' => true, + ], $options)); + if (! $resp->ok()) { + Log::warning('telegram.send failed', [ + 'tenant' => $company->slug, + 'status' => $resp->status(), + 'body' => $resp->body(), + ]); + return false; + } + return true; + } catch (\Throwable $e) { + Log::warning('telegram.send exception', ['err' => $e->getMessage()]); + return false; + } + } + + public function setWebhook(Company $company): array + { + $token = $this->tokenFor($company); + if (! $token) return ['ok' => false, 'error' => 'Lipsește bot token în setări.']; + + $secret = $this->webhookSecretFor($company); + if (! $secret) { + $secret = \Illuminate\Support\Str::random(32); + $company->update([ + 'settings' => array_replace_recursive((array) $company->settings, [ + 'telegram' => ['webhook_secret' => $secret], + ]), + ]); + } + + try { + $resp = Http::asJson()->post(self::API . $token . '/setWebhook', [ + 'url' => $this->webhookUrlFor($company), + 'secret_token' => $secret, + 'allowed_updates' => ['message', 'callback_query'], + ]); + return ['ok' => $resp->ok(), 'response' => $resp->json()]; + } catch (\Throwable $e) { + return ['ok' => false, 'error' => $e->getMessage()]; + } + } + + public function getMe(Company $company): array + { + $token = $this->tokenFor($company); + if (! $token) return ['ok' => false, 'error' => 'no_token']; + try { + $resp = Http::timeout(10)->get(self::API . $token . '/getMe'); + return ['ok' => $resp->ok(), 'response' => $resp->json()]; + } catch (\Throwable $e) { + return ['ok' => false, 'error' => $e->getMessage()]; + } + } +} diff --git a/database/migrations/2026_05_27_180000_add_notification_channels.php b/database/migrations/2026_05_27_180000_add_notification_channels.php new file mode 100644 index 0000000..4edf12c --- /dev/null +++ b/database/migrations/2026_05_27_180000_add_notification_channels.php @@ -0,0 +1,39 @@ +string('telegram_chat_id', 32)->nullable()->after('telegram'); + $t->json('notify_prefs')->nullable()->after('telegram_chat_id'); + $t->index(['company_id', 'telegram_chat_id']); + }); + + Schema::create('service_reminders_sent', function (Blueprint $t) { + $t->id(); + $t->foreignId('company_id')->constrained()->cascadeOnDelete(); + $t->foreignId('vehicle_id')->constrained()->cascadeOnDelete(); + $t->foreignId('client_id')->nullable()->constrained()->nullOnDelete(); + $t->string('channel', 16); // email / telegram + $t->string('type', 24)->default('general'); // general / inspection / oil / etc + $t->dateTime('sent_at'); + $t->timestamps(); + + $t->index(['company_id', 'vehicle_id', 'sent_at']); + }); + } + + public function down(): void + { + Schema::dropIfExists('service_reminders_sent'); + Schema::table('clients', function (Blueprint $t) { + $t->dropIndex(['company_id', 'telegram_chat_id']); + $t->dropColumn(['telegram_chat_id', 'notify_prefs']); + }); + } +}; diff --git a/routes/console.php b/routes/console.php index 88425c2..3ec6ca6 100644 --- a/routes/console.php +++ b/routes/console.php @@ -24,3 +24,9 @@ ScheduleFacade::command('suppliers:rate --days=90') ->weeklyOn(1, '04:00') ->withoutOverlapping() ->onOneServer(); + +// Daily service reminders at 09:00 (tenant-local time = UTC; adjust per-tenant later). +ScheduleFacade::command('reminders:send') + ->dailyAt('09:00') + ->withoutOverlapping() + ->onOneServer(); diff --git a/routes/web.php b/routes/web.php index 27c678e..92feba7 100644 --- a/routes/web.php +++ b/routes/web.php @@ -57,6 +57,16 @@ Route::get('/login', function (Request $request) { return redirect($tenant ? '/app/login' : '/admin/login'); })->name('login'); +// ─── Telegram webhook (per-tenant, on central domain) ────────────── +Route::post('/telegram/webhook/{slug}', [\App\Http\Controllers\TelegramWebhookController::class, 'handle']) + ->where('slug', '[a-z0-9\-]+') + ->withoutMiddleware([ + \Illuminate\Foundation\Http\Middleware\VerifyCsrfToken::class, + \App\Http\Middleware\ResolveTenant::class, + \App\Http\Middleware\CheckTenantStatus::class, + ]) + ->name('telegram.webhook'); + // ─── Public WO tracking (no auth, tenant-scoped via subdomain) ────── Route::get('/t/{token}', [\App\Http\Controllers\TrackingController::class, 'show']) ->where('token', '[A-Za-z0-9]{16,32}') diff --git a/tests/Feature/TelegramNotificationsTest.php b/tests/Feature/TelegramNotificationsTest.php new file mode 100644 index 0000000..4161571 --- /dev/null +++ b/tests/Feature/TelegramNotificationsTest.php @@ -0,0 +1,192 @@ +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; + } +}