feat: Add notification services for Email, Telegram, and WhatsApp
- Implement EmailNotifier for sending batch and test notifications via email. - Implement TelegramNotifier for sending batch and test notifications via Telegram. - Implement WahaNotifier for sending batch and test notifications via WhatsApp using WAHA API. - Create migration for client_categories table and update clients table to include category_id. - Create migration for notification_settings table to manage notification configurations. - Add ClientCategorySeeder to populate default client categories. - Create manifest.json for PWA support. - Implement service worker (sw.js) for caching static assets and offline support. - Add edit event view for modifying scheduled events. - Create search results view for displaying search queries. - Develop notification settings view for configuring notification channels and triggers.
This commit is contained in:
71
app/Http/Controllers/ClientCategoryController.php
Normal file
71
app/Http/Controllers/ClientCategoryController.php
Normal file
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\ClientCategory;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class ClientCategoryController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
// Managed inline from the clients page; redirect there
|
||||
return redirect()->route('clients.index');
|
||||
}
|
||||
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:100',
|
||||
'parent_id' => 'nullable|exists:client_categories,id',
|
||||
'icon' => 'nullable|string|max:10',
|
||||
'color' => 'nullable|string|max:20',
|
||||
]);
|
||||
|
||||
// Auto slug
|
||||
$validated['slug'] = \Illuminate\Support\Str::slug($validated['name']);
|
||||
|
||||
// If slug conflicts, suffix with random
|
||||
if (ClientCategory::where('slug', $validated['slug'])->exists()) {
|
||||
$validated['slug'] .= '-' . substr(uniqid(), -4);
|
||||
}
|
||||
|
||||
ClientCategory::create($validated);
|
||||
|
||||
return back()->with('success', '🏷️ Category added!');
|
||||
}
|
||||
|
||||
public function update(Request $request, ClientCategory $category): RedirectResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:100',
|
||||
'parent_id' => 'nullable|exists:client_categories,id',
|
||||
'icon' => 'nullable|string|max:10',
|
||||
'color' => 'nullable|string|max:20',
|
||||
]);
|
||||
|
||||
// Prevent a category from being its own parent
|
||||
if (isset($validated['parent_id']) && $validated['parent_id'] == $category->id) {
|
||||
$validated['parent_id'] = null;
|
||||
}
|
||||
|
||||
$category->update($validated);
|
||||
|
||||
return back()->with('success', '🏷️ Category updated!');
|
||||
}
|
||||
|
||||
public function destroy(ClientCategory $category): RedirectResponse
|
||||
{
|
||||
// Reassign children to the parent
|
||||
$category->children()->update(['parent_id' => $category->parent_id]);
|
||||
// Detach clients
|
||||
$category->clients()->update(['category_id' => null]);
|
||||
|
||||
$category->delete();
|
||||
|
||||
return back()->with('success', '🏷️ Category removed.');
|
||||
}
|
||||
}
|
||||
@@ -6,25 +6,61 @@ namespace App\Http\Controllers;
|
||||
|
||||
use App\Enums\ClientStatus;
|
||||
use App\Models\Client;
|
||||
use App\Models\ClientCategory;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class ClientController extends Controller
|
||||
{
|
||||
public function index(): View
|
||||
private function categoryTree(): \Illuminate\Support\Collection
|
||||
{
|
||||
$clients = Client::withCount(['invoices', 'events', 'contentBriefs'])
|
||||
->latest()
|
||||
->paginate(12);
|
||||
return ClientCategory::with('children')
|
||||
->whereNull('parent_id')
|
||||
->orderBy('sort_order')
|
||||
->get();
|
||||
}
|
||||
|
||||
return view('clients.index', compact('clients'));
|
||||
private function flatCategories(): \Illuminate\Support\Collection
|
||||
{
|
||||
$flat = collect();
|
||||
foreach ($this->categoryTree() as $parent) {
|
||||
$flat->push($parent);
|
||||
foreach ($parent->children as $child) {
|
||||
$flat->push($child);
|
||||
}
|
||||
}
|
||||
return $flat;
|
||||
}
|
||||
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$query = Client::with('category')
|
||||
->withCount(['invoices', 'events', 'contentBriefs'])
|
||||
->latest();
|
||||
|
||||
if ($request->filled('category')) {
|
||||
$query->where('category_id', $request->category);
|
||||
}
|
||||
|
||||
if ($request->filled('status')) {
|
||||
$query->where('status', $request->status);
|
||||
}
|
||||
|
||||
$clients = $query->paginate(12)->withQueryString();
|
||||
$categories = $this->categoryTree();
|
||||
$statuses = ClientStatus::cases();
|
||||
|
||||
return view('clients.index', compact('clients', 'categories', 'statuses'));
|
||||
}
|
||||
|
||||
public function create(): View
|
||||
{
|
||||
$statuses = ClientStatus::cases();
|
||||
return view('clients.create', compact('statuses'));
|
||||
$statuses = ClientStatus::cases();
|
||||
$categories = $this->flatCategories();
|
||||
$categoryTree = $this->categoryTree();
|
||||
|
||||
return view('clients.create', compact('statuses', 'categories', 'categoryTree'));
|
||||
}
|
||||
|
||||
public function store(Request $request): RedirectResponse
|
||||
@@ -44,6 +80,7 @@ class ClientController extends Controller
|
||||
'postal_code' => 'nullable|string|max:20',
|
||||
'country' => 'nullable|string|max:100',
|
||||
'status' => 'required|in:active,inactive,prospect',
|
||||
'category_id' => 'nullable|exists:client_categories,id',
|
||||
'notes' => 'nullable|string',
|
||||
]);
|
||||
|
||||
@@ -55,18 +92,24 @@ class ClientController extends Controller
|
||||
|
||||
public function show(Client $client): View
|
||||
{
|
||||
$client->load(['events' => fn ($q) => $q->latest()->limit(5)]);
|
||||
$client->load(['invoices' => fn ($q) => $q->latest()->limit(5)]);
|
||||
$client->load(['contentBriefs' => fn ($q) => $q->latest()->limit(5)]);
|
||||
$client->load(['mediaFiles' => fn ($q) => $q->latest()->limit(10)]);
|
||||
$client->load([
|
||||
'category',
|
||||
'events' => fn ($q) => $q->latest()->limit(5),
|
||||
'invoices' => fn ($q) => $q->latest()->limit(5),
|
||||
'contentBriefs'=> fn ($q) => $q->latest()->limit(5),
|
||||
'mediaFiles' => fn ($q) => $q->latest()->limit(10),
|
||||
]);
|
||||
|
||||
return view('clients.show', compact('client'));
|
||||
}
|
||||
|
||||
public function edit(Client $client): View
|
||||
{
|
||||
$statuses = ClientStatus::cases();
|
||||
return view('clients.edit', compact('client', 'statuses'));
|
||||
$statuses = ClientStatus::cases();
|
||||
$categories = $this->flatCategories();
|
||||
$categoryTree = $this->categoryTree();
|
||||
|
||||
return view('clients.edit', compact('client', 'statuses', 'categories', 'categoryTree'));
|
||||
}
|
||||
|
||||
public function update(Request $request, Client $client): RedirectResponse
|
||||
@@ -86,6 +129,7 @@ class ClientController extends Controller
|
||||
'postal_code' => 'nullable|string|max:20',
|
||||
'country' => 'nullable|string|max:100',
|
||||
'status' => 'required|in:active,inactive,prospect',
|
||||
'category_id' => 'nullable|exists:client_categories,id',
|
||||
'notes' => 'nullable|string',
|
||||
]);
|
||||
|
||||
|
||||
@@ -63,6 +63,14 @@ class EventController extends Controller
|
||||
->with('success', '🐾 Event scheduled!');
|
||||
}
|
||||
|
||||
public function edit(Event $event): View
|
||||
{
|
||||
$clients = Client::where('status', 'active')->orderBy('name')->get();
|
||||
$statuses = EventStatus::cases();
|
||||
|
||||
return view('schedule.edit', compact('event', 'clients', 'statuses'));
|
||||
}
|
||||
|
||||
public function update(Request $request, Event $event): RedirectResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
|
||||
97
app/Http/Controllers/NotificationSettingController.php
Normal file
97
app/Http/Controllers/NotificationSettingController.php
Normal file
@@ -0,0 +1,97 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\NotificationSetting;
|
||||
use App\Services\NotificationDispatcher;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class NotificationSettingController extends Controller
|
||||
{
|
||||
public function index(): View
|
||||
{
|
||||
$settings = NotificationSetting::orderBy('channel')->orderBy('name')->get();
|
||||
|
||||
return view('settings.notifications', compact('settings'));
|
||||
}
|
||||
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:100',
|
||||
'channel' => 'required|in:email,telegram,whatsapp',
|
||||
'triggers' => 'nullable|array',
|
||||
'triggers.*' => 'in:upcoming_event,invoice_due,content_due',
|
||||
'notify_before_hours' => 'nullable|integer|min:1|max:720',
|
||||
// Email
|
||||
'config.to_email' => 'required_if:channel,email|nullable|email',
|
||||
// Telegram
|
||||
'config.bot_token' => 'required_if:channel,telegram|nullable|string',
|
||||
'config.chat_id' => 'required_if:channel,telegram|nullable|string',
|
||||
// WhatsApp (WAHA)
|
||||
'config.waha_url' => 'required_if:channel,whatsapp|nullable|url',
|
||||
'config.waha_session' => 'required_if:channel,whatsapp|nullable|string',
|
||||
'config.phone' => 'required_if:channel,whatsapp|nullable|string',
|
||||
]);
|
||||
|
||||
NotificationSetting::create([
|
||||
'name' => $validated['name'],
|
||||
'channel' => $validated['channel'],
|
||||
'config' => $validated['config'] ?? [],
|
||||
'triggers' => $validated['triggers'] ?? [],
|
||||
'notify_before_hours' => $validated['notify_before_hours'] ?? 24,
|
||||
'is_enabled' => true,
|
||||
]);
|
||||
|
||||
return back()->with('success', '🔔 Notification channel added!');
|
||||
}
|
||||
|
||||
public function update(Request $request, NotificationSetting $setting): RedirectResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:100',
|
||||
'triggers' => 'nullable|array',
|
||||
'triggers.*' => 'in:upcoming_event,invoice_due,content_due',
|
||||
'notify_before_hours' => 'nullable|integer|min:1|max:720',
|
||||
'config' => 'nullable|array',
|
||||
]);
|
||||
|
||||
$setting->update([
|
||||
'name' => $validated['name'],
|
||||
'triggers' => $validated['triggers'] ?? [],
|
||||
'notify_before_hours' => $validated['notify_before_hours'] ?? $setting->notify_before_hours,
|
||||
'config' => $validated['config'] ?? $setting->config,
|
||||
]);
|
||||
|
||||
return back()->with('success', '🔔 Notification updated!');
|
||||
}
|
||||
|
||||
public function destroy(NotificationSetting $setting): RedirectResponse
|
||||
{
|
||||
$setting->delete();
|
||||
|
||||
return back()->with('success', '🔔 Channel removed.');
|
||||
}
|
||||
|
||||
public function toggle(NotificationSetting $setting): RedirectResponse
|
||||
{
|
||||
$setting->update(['is_enabled' => ! $setting->is_enabled]);
|
||||
|
||||
return back()->with('success', $setting->is_enabled ? '🔔 Enabled!' : '🔕 Disabled.');
|
||||
}
|
||||
|
||||
public function test(NotificationSetting $setting): RedirectResponse
|
||||
{
|
||||
try {
|
||||
app(NotificationDispatcher::class)->sendTest($setting);
|
||||
return back()->with('success', '✅ Test notification sent!');
|
||||
} catch (\Throwable $e) {
|
||||
return back()->with('error', '❌ Failed: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
143
app/Http/Controllers/SearchController.php
Normal file
143
app/Http/Controllers/SearchController.php
Normal file
@@ -0,0 +1,143 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Client;
|
||||
use App\Models\ContentBrief;
|
||||
use App\Models\Event;
|
||||
use App\Models\Invoice;
|
||||
use App\Models\RateCard;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class SearchController extends Controller
|
||||
{
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$q = trim($request->get('q', ''));
|
||||
$results = $q ? $this->search($q) : [];
|
||||
|
||||
return view('search.index', compact('q', 'results'));
|
||||
}
|
||||
|
||||
/** Quick AJAX endpoint — returns grouped results for the dropdown */
|
||||
public function quick(Request $request): JsonResponse
|
||||
{
|
||||
$q = trim($request->get('q', ''));
|
||||
|
||||
if (strlen($q) < 2) {
|
||||
return response()->json([]);
|
||||
}
|
||||
|
||||
return response()->json($this->search($q, limit: 5));
|
||||
}
|
||||
|
||||
private function search(string $q, int $limit = 20): array
|
||||
{
|
||||
$groups = [];
|
||||
$like = "%{$q}%";
|
||||
|
||||
// ── Clients ──────────────────────────────────────────────
|
||||
$clients = Client::where('name', 'LIKE', $like)
|
||||
->orWhere('email', 'LIKE', $like)
|
||||
->orWhere('company', 'LIKE', $like)
|
||||
->orWhere('ig_handle', 'LIKE', $like)
|
||||
->orWhere('tiktok_handle', 'LIKE', $like)
|
||||
->limit($limit)->get();
|
||||
|
||||
if ($clients->isNotEmpty()) {
|
||||
$groups[] = [
|
||||
'type' => 'clients',
|
||||
'label' => '👥 Clients',
|
||||
'items' => $clients->map(fn ($c) => [
|
||||
'icon' => '👥',
|
||||
'title' => $c->name,
|
||||
'subtitle' => collect([$c->company, $c->email])->filter()->implode(' · '),
|
||||
'url' => route('clients.show', $c),
|
||||
])->values()->all(),
|
||||
];
|
||||
}
|
||||
|
||||
// ── Events ───────────────────────────────────────────────
|
||||
$events = Event::with('client')
|
||||
->where('title', 'LIKE', $like)
|
||||
->orWhere('description', 'LIKE', $like)
|
||||
->orWhere('location', 'LIKE', $like)
|
||||
->limit($limit)->get();
|
||||
|
||||
if ($events->isNotEmpty()) {
|
||||
$groups[] = [
|
||||
'type' => 'events',
|
||||
'label' => '📅 Events',
|
||||
'items' => $events->map(fn ($e) => [
|
||||
'icon' => '📅',
|
||||
'title' => $e->title,
|
||||
'subtitle' => $e->start_time->format('d M Y') . ($e->client ? ' · ' . $e->client->name : ''),
|
||||
'url' => route('schedule.index'),
|
||||
])->values()->all(),
|
||||
];
|
||||
}
|
||||
|
||||
// ── Invoices ─────────────────────────────────────────────
|
||||
$invoices = Invoice::with('client')
|
||||
->where('invoice_number', 'LIKE', $like)
|
||||
->orWhere('title', 'LIKE', $like)
|
||||
->limit($limit)->get();
|
||||
|
||||
if ($invoices->isNotEmpty()) {
|
||||
$groups[] = [
|
||||
'type' => 'invoices',
|
||||
'label' => '🧾 Invoices',
|
||||
'items' => $invoices->map(fn ($i) => [
|
||||
'icon' => '🧾',
|
||||
'title' => $i->invoice_number . ($i->title ? ' — ' . $i->title : ''),
|
||||
'subtitle' => ($i->client?->name ?? '') . ' · ' . ($i->formatted_total ?? ''),
|
||||
'url' => route('invoices.show', $i),
|
||||
])->values()->all(),
|
||||
];
|
||||
}
|
||||
|
||||
// ── Content Briefs ───────────────────────────────────────
|
||||
$content = ContentBrief::with('client')
|
||||
->where('title', 'LIKE', $like)
|
||||
->orWhere('description', 'LIKE', $like)
|
||||
->orWhere('platform', 'LIKE', $like)
|
||||
->limit($limit)->get();
|
||||
|
||||
if ($content->isNotEmpty()) {
|
||||
$groups[] = [
|
||||
'type' => 'content',
|
||||
'label' => '🎬 Content',
|
||||
'items' => $content->map(fn ($c) => [
|
||||
'icon' => $c->platform_icon ?? '🎬',
|
||||
'title' => $c->title,
|
||||
'subtitle' => ucfirst($c->platform) . ($c->client ? ' · ' . $c->client->name : ''),
|
||||
'url' => route('content.show', $c),
|
||||
])->values()->all(),
|
||||
];
|
||||
}
|
||||
|
||||
// ── Rate Cards ───────────────────────────────────────────
|
||||
$rateCards = RateCard::where('name', 'LIKE', $like)
|
||||
->orWhere('description', 'LIKE', $like)
|
||||
->limit($limit)->get();
|
||||
|
||||
if ($rateCards->isNotEmpty()) {
|
||||
$groups[] = [
|
||||
'type' => 'rate-cards',
|
||||
'label' => '💰 Rate Cards',
|
||||
'items' => $rateCards->map(fn ($r) => [
|
||||
'icon' => '💰',
|
||||
'title' => $r->name,
|
||||
'subtitle' => $r->description ?? '',
|
||||
'url' => route('rate-cards.edit', $r),
|
||||
])->values()->all(),
|
||||
];
|
||||
}
|
||||
|
||||
return $groups;
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ namespace App\Models;
|
||||
use App\Enums\ClientStatus;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
@@ -18,13 +19,20 @@ class Client extends Model
|
||||
'name', 'email', 'phone', 'company',
|
||||
'ig_handle', 'tiktok_handle', 'youtube_handle',
|
||||
'address_line1', 'address_line2', 'city', 'state', 'postal_code', 'country',
|
||||
'status', 'notes', 'avatar',
|
||||
'status', 'category_id', 'notes', 'avatar',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'status' => ClientStatus::class,
|
||||
];
|
||||
|
||||
// ── Relationships ────────────────────────────────────────────
|
||||
|
||||
public function category(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(ClientCategory::class, 'category_id');
|
||||
}
|
||||
|
||||
public function events(): HasMany
|
||||
{
|
||||
return $this->hasMany(Event::class);
|
||||
@@ -45,6 +53,8 @@ class Client extends Model
|
||||
return $this->hasMany(MediaFile::class);
|
||||
}
|
||||
|
||||
// ── Accessors ────────────────────────────────────────────────
|
||||
|
||||
public function getFullAddressAttribute(): string
|
||||
{
|
||||
return collect([
|
||||
|
||||
74
app/Models/ClientCategory.php
Normal file
74
app/Models/ClientCategory.php
Normal file
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class ClientCategory extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'name', 'slug', 'parent_id', 'icon', 'color',
|
||||
'description', 'is_system', 'sort_order',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'is_system' => 'boolean',
|
||||
'sort_order' => 'integer',
|
||||
];
|
||||
|
||||
// ── Relationships ────────────────────────────────────────────
|
||||
|
||||
public function parent(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(ClientCategory::class, 'parent_id');
|
||||
}
|
||||
|
||||
public function children(): HasMany
|
||||
{
|
||||
return $this->hasMany(ClientCategory::class, 'parent_id')->orderBy('sort_order');
|
||||
}
|
||||
|
||||
public function clients(): HasMany
|
||||
{
|
||||
return $this->hasMany(Client::class, 'category_id');
|
||||
}
|
||||
|
||||
// ── Scopes ───────────────────────────────────────────────────
|
||||
|
||||
public function scopeRoots($query)
|
||||
{
|
||||
return $query->whereNull('parent_id')->orderBy('sort_order');
|
||||
}
|
||||
|
||||
public function scopeTopLevel($query)
|
||||
{
|
||||
return $query->whereNull('parent_id');
|
||||
}
|
||||
|
||||
// ── Accessors ────────────────────────────────────────────────
|
||||
|
||||
public function getFullNameAttribute(): string
|
||||
{
|
||||
return $this->parent
|
||||
? $this->parent->name . ' › ' . $this->name
|
||||
: $this->name;
|
||||
}
|
||||
|
||||
// ── Events ───────────────────────────────────────────────────
|
||||
|
||||
protected static function boot(): void
|
||||
{
|
||||
parent::boot();
|
||||
|
||||
static::creating(function (ClientCategory $cat): void {
|
||||
if (empty($cat->slug)) {
|
||||
$cat->slug = Str::slug($cat->name);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
53
app/Models/NotificationSetting.php
Normal file
53
app/Models/NotificationSetting.php
Normal file
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class NotificationSetting extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'name', 'channel', 'config', 'triggers',
|
||||
'is_enabled', 'notify_before_hours',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'config' => 'array',
|
||||
'triggers' => 'array',
|
||||
'is_enabled' => 'boolean',
|
||||
'notify_before_hours' => 'integer',
|
||||
];
|
||||
|
||||
// ── Channel icons/labels ─────────────────────────────────────
|
||||
|
||||
public static array $channelMeta = [
|
||||
'email' => ['icon' => '✉️', 'label' => 'Email', 'color' => '#AEC6CF'],
|
||||
'telegram' => ['icon' => '✈️', 'label' => 'Telegram', 'color' => '#E8D5F5'],
|
||||
'whatsapp' => ['icon' => '💬', 'label' => 'WhatsApp', 'color' => '#B5EAD7'],
|
||||
];
|
||||
|
||||
public function getChannelIconAttribute(): string
|
||||
{
|
||||
return static::$channelMeta[$this->channel]['icon'] ?? '🔔';
|
||||
}
|
||||
|
||||
public function getChannelLabelAttribute(): string
|
||||
{
|
||||
return static::$channelMeta[$this->channel]['label'] ?? ucfirst($this->channel);
|
||||
}
|
||||
|
||||
// ── Trigger helpers ──────────────────────────────────────────
|
||||
|
||||
public static array $triggerMeta = [
|
||||
'upcoming_event' => ['icon' => '📅', 'label' => 'Upcoming Events'],
|
||||
'invoice_due' => ['icon' => '🧾', 'label' => 'Invoice Due Date'],
|
||||
'content_due' => ['icon' => '🎬', 'label' => 'Content Target Date'],
|
||||
];
|
||||
|
||||
public function hasTrigger(string $trigger): bool
|
||||
{
|
||||
return in_array($trigger, $this->triggers ?? []);
|
||||
}
|
||||
}
|
||||
113
app/Services/NotificationDispatcher.php
Normal file
113
app/Services/NotificationDispatcher.php
Normal file
@@ -0,0 +1,113 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\ContentBrief;
|
||||
use App\Models\Event;
|
||||
use App\Models\Invoice;
|
||||
use App\Models\NotificationSetting;
|
||||
use App\Services\Notifications\EmailNotifier;
|
||||
use App\Services\Notifications\TelegramNotifier;
|
||||
use App\Services\Notifications\WahaNotifier;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class NotificationDispatcher
|
||||
{
|
||||
/** Dispatch all due notifications for enabled channels */
|
||||
public function dispatchAll(): void
|
||||
{
|
||||
$settings = NotificationSetting::where('is_enabled', true)->get();
|
||||
|
||||
foreach ($settings as $setting) {
|
||||
try {
|
||||
$this->dispatchForSetting($setting);
|
||||
} catch (\Throwable $e) {
|
||||
Log::error("Notification dispatch failed for [{$setting->name}]: " . $e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function dispatchForSetting(NotificationSetting $setting): void
|
||||
{
|
||||
$triggers = $setting->triggers ?? [];
|
||||
$hours = $setting->notify_before_hours;
|
||||
|
||||
$messages = collect();
|
||||
|
||||
if (in_array('upcoming_event', $triggers)) {
|
||||
$events = Event::with('client')
|
||||
->where('start_time', '>=', now())
|
||||
->where('start_time', '<=', now()->addHours($hours))
|
||||
->whereNotIn('status', ['completed', 'cancelled'])
|
||||
->get();
|
||||
|
||||
foreach ($events as $event) {
|
||||
$messages->push(
|
||||
"📅 *Upcoming Event*\n"
|
||||
. "*{$event->title}*\n"
|
||||
. "⏰ " . $event->start_time->format('d M Y, H:i') . "\n"
|
||||
. ($event->client ? "👤 {$event->client->name}\n" : '')
|
||||
. ($event->location ? "📍 {$event->location}" : '')
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (in_array('invoice_due', $triggers)) {
|
||||
$invoices = Invoice::with('client')
|
||||
->whereBetween('due_date', [now(), now()->addHours($hours)])
|
||||
->whereNotIn('status', ['paid', 'cancelled'])
|
||||
->get();
|
||||
|
||||
foreach ($invoices as $inv) {
|
||||
$messages->push(
|
||||
"🧾 *Invoice Due Soon*\n"
|
||||
. "*{$inv->invoice_number}*"
|
||||
. ($inv->title ? " — {$inv->title}" : '') . "\n"
|
||||
. "📅 Due: " . $inv->due_date->format('d M Y') . "\n"
|
||||
. ($inv->client ? "👤 {$inv->client->name}\n" : '')
|
||||
. ($inv->formatted_total ?? '')
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (in_array('content_due', $triggers)) {
|
||||
$content = ContentBrief::with('client')
|
||||
->whereBetween('target_date', [now(), now()->addHours($hours)])
|
||||
->whereNotIn('status', ['posted', 'archived'])
|
||||
->get();
|
||||
|
||||
foreach ($content as $c) {
|
||||
$messages->push(
|
||||
"🎬 *Content Target*\n"
|
||||
. "*{$c->title}*\n"
|
||||
. "📅 " . $c->target_date->format('d M Y') . "\n"
|
||||
. ucfirst($c->platform)
|
||||
. ($c->client ? " · {$c->client->name}" : '')
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if ($messages->isEmpty()) return;
|
||||
|
||||
$notifier = $this->resolveNotifier($setting);
|
||||
$notifier->sendBatch($setting, $messages->all());
|
||||
}
|
||||
|
||||
/** Send a simple test message */
|
||||
public function sendTest(NotificationSetting $setting): void
|
||||
{
|
||||
$this->resolveNotifier($setting)->sendTest($setting);
|
||||
}
|
||||
|
||||
private function resolveNotifier(NotificationSetting $setting): EmailNotifier|TelegramNotifier|WahaNotifier
|
||||
{
|
||||
return match ($setting->channel) {
|
||||
'email' => new EmailNotifier,
|
||||
'telegram' => new TelegramNotifier,
|
||||
'whatsapp' => new WahaNotifier,
|
||||
default => throw new \InvalidArgumentException("Unknown channel: {$setting->channel}"),
|
||||
};
|
||||
}
|
||||
}
|
||||
43
app/Services/Notifications/EmailNotifier.php
Normal file
43
app/Services/Notifications/EmailNotifier.php
Normal file
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Notifications;
|
||||
|
||||
use App\Models\NotificationSetting;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
|
||||
class EmailNotifier
|
||||
{
|
||||
public function sendBatch(NotificationSetting $setting, array $messages): void
|
||||
{
|
||||
$toEmail = $setting->config['to_email'] ?? null;
|
||||
|
||||
if (! $toEmail) {
|
||||
throw new \RuntimeException('Email to_email address is required.');
|
||||
}
|
||||
|
||||
$combined = implode("\n\n---\n\n", $messages);
|
||||
|
||||
Mail::raw($combined, function ($mail) use ($toEmail, $setting): void {
|
||||
$mail->to($toEmail)
|
||||
->subject("🐱 KOL Manager — {$setting->name} Notifications");
|
||||
});
|
||||
}
|
||||
|
||||
public function sendTest(NotificationSetting $setting): void
|
||||
{
|
||||
$toEmail = $setting->config['to_email'] ?? null;
|
||||
|
||||
if (! $toEmail) {
|
||||
throw new \RuntimeException('Email to_email address is required.');
|
||||
}
|
||||
|
||||
Mail::raw(
|
||||
"🐱 KOL Manager — Test Notification\n\nYour Email channel is configured correctly! 🎉",
|
||||
function ($mail) use ($toEmail): void {
|
||||
$mail->to($toEmail)->subject('🐱 KOL Manager — Test Notification');
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
50
app/Services/Notifications/TelegramNotifier.php
Normal file
50
app/Services/Notifications/TelegramNotifier.php
Normal file
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Notifications;
|
||||
|
||||
use App\Models\NotificationSetting;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
class TelegramNotifier
|
||||
{
|
||||
public function sendBatch(NotificationSetting $setting, array $messages): void
|
||||
{
|
||||
foreach ($messages as $message) {
|
||||
$this->send($setting, $message);
|
||||
}
|
||||
}
|
||||
|
||||
public function sendTest(NotificationSetting $setting): void
|
||||
{
|
||||
$this->send(
|
||||
$setting,
|
||||
"🐱 *KOL Manager — Test Notification*\n\nYour Telegram channel is configured correctly! 🎉"
|
||||
);
|
||||
}
|
||||
|
||||
private function send(NotificationSetting $setting, string $text): void
|
||||
{
|
||||
$config = $setting->config ?? [];
|
||||
$botToken = $config['bot_token'] ?? null;
|
||||
$chatId = $config['chat_id'] ?? null;
|
||||
|
||||
if (! $botToken || ! $chatId) {
|
||||
throw new \RuntimeException('Telegram bot_token and chat_id are required.');
|
||||
}
|
||||
|
||||
$response = Http::timeout(10)->post(
|
||||
"https://api.telegram.org/bot{$botToken}/sendMessage",
|
||||
[
|
||||
'chat_id' => $chatId,
|
||||
'text' => $text,
|
||||
'parse_mode' => 'Markdown',
|
||||
]
|
||||
);
|
||||
|
||||
if (! $response->successful()) {
|
||||
throw new \RuntimeException('Telegram API error: ' . $response->body());
|
||||
}
|
||||
}
|
||||
}
|
||||
55
app/Services/Notifications/WahaNotifier.php
Normal file
55
app/Services/Notifications/WahaNotifier.php
Normal file
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Notifications;
|
||||
|
||||
use App\Models\NotificationSetting;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
/**
|
||||
* WAHA (WhatsApp HTTP API) — self-hosted WhatsApp bridge.
|
||||
* Docs: https://waha.devlike.pro/docs/how-to/send-messages/
|
||||
*/
|
||||
class WahaNotifier
|
||||
{
|
||||
public function sendBatch(NotificationSetting $setting, array $messages): void
|
||||
{
|
||||
foreach ($messages as $message) {
|
||||
$this->send($setting, $message);
|
||||
}
|
||||
}
|
||||
|
||||
public function sendTest(NotificationSetting $setting): void
|
||||
{
|
||||
$this->send(
|
||||
$setting,
|
||||
"🐱 *KOL Manager — Test Notification*\n\nYour WhatsApp channel is configured correctly! 🎉"
|
||||
);
|
||||
}
|
||||
|
||||
private function send(NotificationSetting $setting, string $text): void
|
||||
{
|
||||
$config = $setting->config ?? [];
|
||||
$url = rtrim($config['waha_url'] ?? '', '/');
|
||||
$session = $config['waha_session'] ?? 'default';
|
||||
$phone = $config['phone'] ?? null;
|
||||
|
||||
if (! $url || ! $phone) {
|
||||
throw new \RuntimeException('WAHA waha_url and phone are required.');
|
||||
}
|
||||
|
||||
// Normalise phone: strip non-digits, ensure no leading +
|
||||
$chatId = preg_replace('/[^0-9]/', '', $phone) . '@c.us';
|
||||
|
||||
$response = Http::timeout(15)->post("{$url}/api/sendText", [
|
||||
'session' => $session,
|
||||
'chatId' => $chatId,
|
||||
'text' => $text,
|
||||
]);
|
||||
|
||||
if (! $response->successful()) {
|
||||
throw new \RuntimeException('WAHA API error: ' . $response->body());
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user