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;
}
}
+89 -2
View File
@@ -2,7 +2,9 @@
namespace App\Filament\Tenant\Pages; namespace App\Filament\Tenant\Pages;
use App\Services\Notifications\TelegramService;
use App\Tenancy\TenantManager; use App\Tenancy\TenantManager;
use Filament\Actions;
use Filament\Forms; use Filament\Forms;
use Filament\Notifications\Notification; use Filament\Notifications\Notification;
use Filament\Pages\Page; use Filament\Pages\Page;
@@ -50,6 +52,9 @@ class Settings extends Page
'notify_payment' => $notify['payment'] ?? true, 'notify_payment' => $notify['payment'] ?? true,
'notify_appointment' => $notify['appointment'] ?? true, 'notify_appointment' => $notify['appointment'] ?? true,
'notify_reminder' => $notify['reminder'] ?? 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_default_provider' => $settings['ai']['default_provider'] ?? 'claude',
'ai_claude_key' => $settings['ai']['claude_key'] ?? null, 'ai_claude_key' => $settings['ai']['claude_key'] ?? null,
'ai_gpt_key' => $settings['ai']['gpt_key'] ?? null, 'ai_gpt_key' => $settings['ai']['gpt_key'] ?? null,
@@ -126,8 +131,8 @@ class Settings extends Page
->maxSize(512) ->maxSize(512)
->helperText('PNG/ICO, max 512 KB.'), ->helperText('PNG/ICO, max 512 KB.'),
]), ]),
Schemas\Components\Section::make('Notificări email') Schemas\Components\Section::make('Notificări')
->description('Activează / dezactivează emailurile auto către clienți.') ->description('Activează / dezactivează notificările auto către clienți. Telegram are prioritate dacă clientul are cont legat.')
->columns(2) ->columns(2)
->schema([ ->schema([
Forms\Components\Toggle::make('notify_wo_ready')->label('Mașina e gata de ridicat')->default(true), 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_appointment')->label('Programare confirmată')->default(true),
Forms\Components\Toggle::make('notify_reminder')->label('Reminder ITP / revizie')->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') Schemas\Components\Section::make('Asistent AI')
->description('Adaugă chei API ca să activezi asistentul. Cheile rămân la voi — nu sunt partajate.') ->description('Adaugă chei API ca să activezi asistentul. Cheile rămân la voi — nu sunt partajate.')
->columns(2) ->columns(2)
@@ -178,6 +210,14 @@ class Settings extends Page
'appointment' => (bool) ($data['notify_appointment'] ?? true), 'appointment' => (bool) ($data['notify_appointment'] ?? true),
'reminder' => (bool) ($data['notify_reminder'] ?? 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' => [ 'ai' => [
'default_provider' => $data['ai_default_provider'] ?? 'claude', 'default_provider' => $data['ai_default_provider'] ?? 'claude',
'claude_key' => $data['ai_claude_key'] ?? null, 'claude_key' => $data['ai_claude_key'] ?? null,
@@ -201,4 +241,51 @@ class Settings extends Page
Notification::make()->title('Setări salvate')->success()->send(); 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();
}),
];
}
} }
@@ -73,6 +73,14 @@ class ClientResource extends Resource
Forms\Components\TextInput::make('phone_alt')->label('Telefon alternativ')->tel()->maxLength(40), Forms\Components\TextInput::make('phone_alt')->label('Telefon alternativ')->tel()->maxLength(40),
Forms\Components\TextInput::make('email')->email()->maxLength(120), Forms\Components\TextInput::make('email')->email()->maxLength(120),
Forms\Components\TextInput::make('telegram')->maxLength(60), 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('whatsapp')->maxLength(60),
Forms\Components\TextInput::make('viber')->maxLength(60), Forms\Components\TextInput::make('viber')->maxLength(60),
]), ]),
@@ -0,0 +1,100 @@
<?php
namespace App\Http\Controllers;
use App\Models\Central\Company;
use App\Models\Tenant\Client;
use App\Services\Notifications\TelegramService;
use App\Tenancy\TenantManager;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
/**
* Receives Telegram updates per tenant. URL: /telegram/webhook/{slug}
*
* To link a Telegram account to a Client record, the bot expects the user
* to share their phone via Telegram's contact share button (Telegram lets
* users send their own phone with one tap). We match the shared phone (or
* the message text fallback) to clients.phone and persist chat_id.
*/
class TelegramWebhookController extends Controller
{
public function handle(Request $request, string $slug, TelegramService $telegram)
{
$company = Company::where('slug', $slug)->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 — <b>{$client->name}</b>.\n" .
"Vei primi aici notificări despre fișele tale de la <b>{$name}</b>.\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]);
}
}
+11 -1
View File
@@ -16,7 +16,8 @@ class Client extends Model
protected $fillable = [ protected $fillable = [
'company_id', 'type', 'name', 'company_name', 'company_id', 'type', 'name', 'company_name',
'phone', 'phone_alt', 'email', 'phone', 'phone_alt', 'email',
'telegram', 'whatsapp', 'viber', 'telegram', 'telegram_chat_id', 'whatsapp', 'viber',
'notify_prefs',
'source', 'marketing_channel', 'status', 'source', 'marketing_channel', 'status',
'balance', 'discount_pct', 'notes', 'balance', 'discount_pct', 'notes',
'assigned_to', 'last_contact_at', 'assigned_to', 'last_contact_at',
@@ -26,8 +27,17 @@ class Client extends Model
'balance' => 'decimal:2', 'balance' => 'decimal:2',
'discount_pct' => 'decimal:2', 'discount_pct' => 'decimal:2',
'last_contact_at' => 'datetime', '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 public function vehicles(): HasMany
{ {
return $this->hasMany(Vehicle::class); return $this->hasMany(Vehicle::class);
+33
View File
@@ -0,0 +1,33 @@
<?php
namespace App\Models\Tenant;
use App\Models\Concerns\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class ServiceReminderSent extends Model
{
use BelongsToTenant;
protected $table = 'service_reminders_sent';
protected $fillable = [
'company_id', 'vehicle_id', 'client_id',
'channel', 'type', 'sent_at',
];
protected $casts = [
'sent_at' => 'datetime',
];
public function vehicle(): BelongsTo
{
return $this->belongsTo(Vehicle::class);
}
public function client(): BelongsTo
{
return $this->belongsTo(Client::class);
}
}
+177 -50
View File
@@ -8,85 +8,220 @@ use App\Mail\ServiceReminderMail;
use App\Mail\WorkOrderReadyMail; use App\Mail\WorkOrderReadyMail;
use App\Models\Central\Company; use App\Models\Central\Company;
use App\Models\Tenant\Appointment; use App\Models\Tenant\Appointment;
use App\Models\Tenant\Client;
use App\Models\Tenant\Payment; use App\Models\Tenant\Payment;
use App\Models\Tenant\Vehicle; use App\Models\Tenant\Vehicle;
use App\Models\Tenant\WorkOrder; use App\Models\Tenant\WorkOrder;
use App\Services\Notifications\TelegramService;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail; use Illuminate\Support\Facades\Mail;
/** /**
* Centralizes outbound email notifications, with per-tenant feature toggles * Multi-channel outbound notifications with per-tenant + per-client opt-in/out.
* stored in companies.settings.notify (e.g., 'wo_ready' => true).
* *
* Usage: * Channels tried in order:
* app(NotificationDispatcher::class)->workOrderReady($workOrder); * 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 class NotificationDispatcher
{ {
public function __construct(private TelegramService $telegram)
{
}
public function workOrderReady(WorkOrder $wo): bool public function workOrderReady(WorkOrder $wo): bool
{ {
$company = $this->companyFor($wo); $company = $this->companyFor($wo);
if (! $this->isEnabled($company, 'wo_ready')) return false; $client = $wo->client;
if (! $client) return false;
$email = $wo->client?->email; return $this->dispatch($company, $client, 'wo_ready', [
if (! $email) return false; 'telegram' => fn () => $this->tgWorkOrderReady($wo, $company, $client),
'email' => fn () => $this->emailSafe(
try { fn () => Mail::to($client->email)->send(new WorkOrderReadyMail($wo, $company)),
Mail::to($email)->send(new WorkOrderReadyMail($wo, $company)); 'workOrderReady', ['wo' => $wo->id]
return true; ),
} catch (\Throwable $e) { ]);
Log::warning('workOrderReady mail failed', ['wo' => $wo->id, 'err' => $e->getMessage()]);
return false;
}
} }
public function paymentReceived(Payment $payment): bool public function paymentReceived(Payment $payment): bool
{ {
$company = $this->companyFor($payment); $company = $this->companyFor($payment);
if (! $this->isEnabled($company, 'payment')) return false; $client = $payment->client;
if (! $client) return false;
$email = $payment->client?->email; return $this->dispatch($company, $client, 'payment', [
if (! $email) return false; 'telegram' => fn () => $this->tgPaymentReceived($payment, $company, $client),
'email' => fn () => $this->emailSafe(
try { fn () => Mail::to($client->email)->send(new PaymentReceivedMail($payment, $company)),
Mail::to($email)->send(new PaymentReceivedMail($payment, $company)); 'paymentReceived', ['payment' => $payment->id]
return true; ),
} catch (\Throwable $e) { ]);
Log::warning('paymentReceived mail failed', ['payment' => $payment->id, 'err' => $e->getMessage()]);
return false;
}
} }
public function appointmentConfirmed(Appointment $a): bool public function appointmentConfirmed(Appointment $a): bool
{ {
$company = $this->companyFor($a); $company = $this->companyFor($a);
if (! $this->isEnabled($company, 'appointment')) return false; $client = $a->client;
if (! $client) return false;
$email = $a->client?->email; return $this->dispatch($company, $client, 'appointment', [
if (! $email) return false; 'telegram' => fn () => $this->tgAppointmentConfirmed($a, $company, $client),
'email' => fn () => $this->emailSafe(
try { fn () => Mail::to($client->email)->send(new AppointmentConfirmedMail($a, $company)),
Mail::to($email)->send(new AppointmentConfirmedMail($a, $company)); 'appointmentConfirmed', ['appt' => $a->id]
return true; ),
} catch (\Throwable $e) { ]);
Log::warning('appointmentConfirmed mail failed', ['appt' => $a->id, 'err' => $e->getMessage()]);
return false;
}
} }
public function serviceReminder(Vehicle $v, string $type = 'general', ?string $note = null): bool public function serviceReminder(Vehicle $v, string $type = 'general', ?string $note = null): bool
{ {
$company = $this->companyFor($v); $company = $this->companyFor($v);
if (! $this->isEnabled($company, 'reminder')) return false; $client = $v->client;
if (! $client) return false;
$email = $v->client?->email; return $this->dispatch($company, $client, 'reminder', [
if (! $email) return false; '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<string, callable(): bool> $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 = "✅ <b>Mașina e gata de ridicat</b>\n"
. "Fișa #{$no} · {$plate}\n"
. "Total: <b>" . number_format((float) $wo->total, 2) . " " . ($company->settings['currency'] ?? 'MDL') . "</b>\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 = "💳 <b>Plată primită</b>\n"
. "Suma: <b>{$amt} {$cur}</b>\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 = "📅 <b>Programare confirmată</b>\n"
. "Data: <b>{$when}</b>\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 = "🔧 <b>Reminder service</b>\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 { try {
Mail::to($email)->send(new ServiceReminderMail($v, $type, $note, $company)); $fn();
return true; return true;
} catch (\Throwable $e) { } 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; return false;
} }
} }
@@ -95,12 +230,4 @@ class NotificationDispatcher
{ {
return Company::withoutGlobalScopes()->findOrFail($model->company_id); 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;
}
} }
@@ -0,0 +1,103 @@
<?php
namespace App\Services\Notifications;
use App\Models\Central\Company;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
/**
* Thin client over Bot API. Tenant-aware: bot token comes from
* companies.settings.telegram.bot_token. Webhook secret used to verify
* incoming updates (Telegram sends it back as X-Telegram-Bot-Api-Secret-Token).
*/
class TelegramService
{
private const API = 'https://api.telegram.org/bot';
public function tokenFor(Company $company): ?string
{
return data_get($company->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()];
}
}
}
@@ -0,0 +1,39 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('clients', function (Blueprint $t) {
$t->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']);
});
}
};
+6
View File
@@ -24,3 +24,9 @@ ScheduleFacade::command('suppliers:rate --days=90')
->weeklyOn(1, '04:00') ->weeklyOn(1, '04:00')
->withoutOverlapping() ->withoutOverlapping()
->onOneServer(); ->onOneServer();
// Daily service reminders at 09:00 (tenant-local time = UTC; adjust per-tenant later).
ScheduleFacade::command('reminders:send')
->dailyAt('09:00')
->withoutOverlapping()
->onOneServer();
+10
View File
@@ -57,6 +57,16 @@ Route::get('/login', function (Request $request) {
return redirect($tenant ? '/app/login' : '/admin/login'); return redirect($tenant ? '/app/login' : '/admin/login');
})->name('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) ────── // ─── Public WO tracking (no auth, tenant-scoped via subdomain) ──────
Route::get('/t/{token}', [\App\Http\Controllers\TrackingController::class, 'show']) Route::get('/t/{token}', [\App\Http\Controllers\TrackingController::class, 'show'])
->where('token', '[A-Za-z0-9]{16,32}') ->where('token', '[A-Za-z0-9]{16,32}')
+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;
}
}