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