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;
+ }
+}