From ab1fdb961f01e6903ce18b3262936d28319ecb2b Mon Sep 17 00:00:00 2001 From: Hickmeister <35031453+Hickmeister@users.noreply.github.com> Date: Sun, 11 Oct 2026 21:02:35 +0100 Subject: [PATCH] Add notification inbox, toasts and alert scanner for orders, prints and filament Co-Authored-By: Claude Sonnet 5.5 --- .../Commands/RefreshIntegrationCache.php | 1 + app/Console/Commands/ScanAlerts.php | 20 ++ .../Controllers/NotificationController.php | 43 ++++ app/Models/AlertState.php | 40 ++++ app/Notifications/DashboardAlert.php | 35 +++ app/Services/AlertScanner.php | 225 ++++++++++++++++++ app/Services/Bambuddy.php | 7 + ...0_11_195906_create_notifications_table.php | 31 +++ ...10_11_195907_create_alert_states_table.php | 28 +++ resources/js/app.js | 2 + resources/js/notifications.js | 143 +++++++++++ resources/views/layouts/app.blade.php | 2 +- .../views/partials/notifications.blade.php | 18 ++ routes/console.php | 1 + routes/web.php | 4 + tests/Feature/NotificationInboxTest.php | 53 +++++ tests/Feature/NotificationScannerTest.php | 153 ++++++++++++ 17 files changed, 805 insertions(+), 1 deletion(-) create mode 100644 app/Console/Commands/ScanAlerts.php create mode 100644 app/Http/Controllers/NotificationController.php create mode 100644 app/Models/AlertState.php create mode 100644 app/Notifications/DashboardAlert.php create mode 100644 app/Services/AlertScanner.php create mode 100644 database/migrations/2026_10_11_195906_create_notifications_table.php create mode 100644 database/migrations/2026_10_11_195907_create_alert_states_table.php create mode 100644 resources/js/notifications.js create mode 100644 resources/views/partials/notifications.blade.php create mode 100644 tests/Feature/NotificationInboxTest.php create mode 100644 tests/Feature/NotificationScannerTest.php diff --git a/app/Console/Commands/RefreshIntegrationCache.php b/app/Console/Commands/RefreshIntegrationCache.php index a76e6e5..a321d9d 100644 --- a/app/Console/Commands/RefreshIntegrationCache.php +++ b/app/Console/Commands/RefreshIntegrationCache.php @@ -39,6 +39,7 @@ class RefreshIntegrationCache extends Command $this->refresh('bambuddy queue', fn () => $bambuddy->queue()); $this->refresh('bambuddy recent prints', fn () => $bambuddy->recentPrints()); $this->refresh('bambuddy stats', fn () => $bambuddy->stats()); + $this->refresh('bambuddy spools', fn () => $bambuddy->spools()); } if ($dryer->configured()) { diff --git a/app/Console/Commands/ScanAlerts.php b/app/Console/Commands/ScanAlerts.php new file mode 100644 index 0000000..467695e --- /dev/null +++ b/app/Console/Commands/ScanAlerts.php @@ -0,0 +1,20 @@ +line($scanner->scan().' notification(s) published'); + + return self::SUCCESS; + } +} diff --git a/app/Http/Controllers/NotificationController.php b/app/Http/Controllers/NotificationController.php new file mode 100644 index 0000000..d4fef18 --- /dev/null +++ b/app/Http/Controllers/NotificationController.php @@ -0,0 +1,43 @@ +user(); + + return response()->json([ + 'unread' => $user->unreadNotifications()->count(), + 'items' => $user->notifications()->latest()->limit(20)->get()->map(fn (DatabaseNotification $notification): array => [ + 'id' => $notification->id, + 'kind' => $notification->data['kind'] ?? 'info', + 'title' => $notification->data['title'] ?? '', + 'body' => $notification->data['body'] ?? '', + 'level' => $notification->data['level'] ?? 'info', + 'url' => $notification->data['url'] ?? null, + 'read' => $notification->read_at !== null, + 'time' => $notification->created_at->diffForHumans(), + ])->all(), + ]); + } + + public function read(Request $request, string $notification): JsonResponse + { + $request->user()->notifications()->whereKey($notification)->first()?->markAsRead(); + + return response()->json(['ok' => true]); + } + + public function readAll(Request $request): JsonResponse + { + $request->user()->unreadNotifications->markAsRead(); + + return response()->json(['ok' => true]); + } +} diff --git a/app/Models/AlertState.php b/app/Models/AlertState.php new file mode 100644 index 0000000..0f93b8d --- /dev/null +++ b/app/Models/AlertState.php @@ -0,0 +1,40 @@ + 'array']; + } + + /** + * @return array|null + */ + public static function read(string $key): ?array + { + return static::find($key)?->state; + } + + /** + * @param array $state + */ + public static function write(string $key, array $state): void + { + static::updateOrCreate(['key' => $key], ['state' => $state]); + } +} diff --git a/app/Notifications/DashboardAlert.php b/app/Notifications/DashboardAlert.php new file mode 100644 index 0000000..32c102d --- /dev/null +++ b/app/Notifications/DashboardAlert.php @@ -0,0 +1,35 @@ + + */ + public function via(object $notifiable): array + { + return ['database']; + } + + /** + * @return array{kind: string, title: string, body: string, level: string, url: string|null} + */ + public function toArray(object $notifiable): array + { + return ['kind' => $this->kind, 'title' => $this->title, 'body' => $this->body, 'level' => $this->level, 'url' => $this->url]; + } +} diff --git a/app/Services/AlertScanner.php b/app/Services/AlertScanner.php new file mode 100644 index 0000000..a426ba0 --- /dev/null +++ b/app/Services/AlertScanner.php @@ -0,0 +1,225 @@ +published = 0; + foreach ([$this->scanOrders(...), $this->scanPrints(...), $this->scanFilament(...)] as $detector) { + try { + $detector(); + } catch (Throwable $exception) { + report($exception); + } + } + + return $this->published; + } + + private function scanOrders(): void + { + $connection = EtsyConnection::first(); + if (! $connection || ! $this->etsy->configured()) { + return; + } + + $receipts = $this->etsy->receipts($connection)['results'] ?? []; + $previous = AlertState::read('etsy.orders'); + $seen = $previous['seen'] ?? []; + $stages = $previous['stages'] ?? []; + $now = time(); + $currentIds = []; + + foreach ($receipts as $receipt) { + $id = (string) ($receipt['receipt_id'] ?? ''); + if ($id === '') { + continue; + } + $currentIds[] = $id; + $buyer = $receipt['name'] ?? $receipt['buyer_email'] ?? 'a buyer'; + $total = $receipt['grandtotal'] ?? []; + $value = ($total['currency_code'] ?? 'GBP').' '.number_format((float) ($total['amount'] ?? 0) / max(1, (int) ($total['divisor'] ?? 1)), 2); + + if ($previous !== null && ! in_array($id, $seen, true)) { + $this->publish('admins', 'order', 'New Etsy order #'.$id, $buyer.' · '.$value, 'success', route('shipping')); + } + + $shipBy = $this->shipByTimestamp($receipt); + if ($shipBy === null) { + continue; + } + $stage = $now >= $shipBy ? 2 : (($shipBy - $now) <= self::DUE_WINDOW_SECONDS ? 1 : 0); + if ($stage > ($stages[$id] ?? 0)) { + $when = $this->display->dateTime($shipBy); + $this->publish( + 'admins', 'shipment', + $stage === 2 ? 'Order #'.$id.' is overdue to ship' : 'Order #'.$id.' is due to ship within 24 hours', + $buyer.' · ship by '.$when, + $stage === 2 ? 'danger' : 'warning', + route('shipping'), + ); + $stages[$id] = $stage; + } + } + + AlertState::write('etsy.orders', [ + 'seen' => array_slice(array_values(array_unique([...$seen, ...$currentIds])), -self::SEEN_ORDER_LIMIT), + 'stages' => array_intersect_key($stages, array_flip($currentIds)), + ]); + } + + /** @param array $receipt */ + private function shipByTimestamp(array $receipt): ?int + { + $dates = collect($receipt['transactions'] ?? []) + ->map(fn ($transaction): int => (int) ($transaction['expected_ship_date'] ?? 0)) + ->filter(fn (int $date): bool => $date > 0); + + return $dates->isEmpty() ? null : $dates->min(); + } + + private function scanPrints(): void + { + if (! $this->bambuddy->configured()) { + return; + } + + foreach ($this->bambuddy->printers() as $printer) { + $live = $printer['live'] ?? []; + if ($live === [] || ($live['connected'] ?? true) === false) { + continue; + } + $this->scanPrinter((int) $printer['id'], (string) ($printer['name'] ?? 'Printer'), $live); + } + } + + /** @param array $live */ + private function scanPrinter(int $id, string $name, array $live): void + { + $key = 'print.'.$id; + $last = AlertState::read($key); + $state = $last ?? ['active' => false, 'job' => null, 'progress' => 0.0, 'notified' => [], 'errors' => null, 'failed' => false]; + $status = strtoupper((string) ($live['state'] ?? '')); + $job = (string) ($live['subtask_name'] ?? $live['current_print'] ?? ''); + $progress = (float) ($live['progress'] ?? 0); + $label = $job !== '' ? $name.' · '.$job : $name; + $url = route('printers.index'); + + if (in_array($status, ['RUNNING', 'PRINTING', 'PREPARE', 'PAUSE'], true)) { + if (! $state['active'] || $state['job'] !== $job || $progress < $state['progress'] - 10) { + $state['notified'] = $last === null ? array_values(array_filter(self::PRINT_MILESTONES, fn (int $milestone): bool => $progress >= $milestone)) : []; + $state['failed'] = false; + } + $crossed = array_values(array_filter(self::PRINT_MILESTONES, fn (int $milestone): bool => $progress >= $milestone && ! in_array($milestone, $state['notified'], true))); + if ($crossed !== []) { + $this->publish('everyone', 'print', $label.' is '.max($crossed).'% complete', $this->remaining($live), 'info', $url); + $state['notified'] = [...$state['notified'], ...$crossed]; + } + $state['active'] = true; + $state['job'] = $job; + $state['progress'] = $progress; + } elseif ($status === 'FINISH') { + if ($state['active']) { + $this->publish('everyone', 'print', $label.' is 100% complete', 'The print has finished.', 'success', $url); + } + $state['active'] = false; + $state['progress'] = 0.0; + } elseif (in_array($status, ['FAILED', 'FAIL'], true)) { + if (! $state['failed'] && $last !== null) { + $this->publish('everyone', 'print-error', $label.' failed', 'The print stopped at '.round($state['progress']).'%.', 'danger', $url); + } + $state['failed'] = true; + $state['active'] = false; + $state['progress'] = 0.0; + } else { + if ($state['active']) { + $this->publish('everyone', 'print-error', $label.' stopped early', 'The print ended at '.round($state['progress']).'%.', 'warning', $url); + } + $state['active'] = false; + $state['progress'] = 0.0; + } + + $errors = is_array($live['hms_errors'] ?? null) ? $live['hms_errors'] : []; + $errorHash = $errors === [] ? null : md5((string) json_encode($errors)); + if ($errorHash !== null && $errorHash !== $state['errors']) { + $this->publish('everyone', 'print-error', $name.' reports '.count($errors).' alert'.(count($errors) === 1 ? '' : 's'), (string) ($errors[0]['description'] ?? 'Open BamBuddy for details.'), 'danger', $url); + } + $state['errors'] = $errorHash; + + AlertState::write($key, $state); + } + + /** @param array $live */ + private function remaining(array $live): string + { + $minutes = (int) ($live['remaining_time'] ?? 0); + + return $minutes > 0 ? $this->display->duration($minutes * 60).' remaining' : 'Print in progress.'; + } + + private function scanFilament(): void + { + if (! $this->bambuddy->configured()) { + return; + } + + $state = AlertState::read('filament.low') ?? ['alerted' => []]; + $alerted = $state['alerted']; + + foreach ($this->bambuddy->spools() as $spool) { + $capacity = (float) ($spool['label_weight'] ?? 0); + if (filled($spool['archived_at'] ?? null) || $capacity <= 0) { + continue; + } + $id = (string) ($spool['id'] ?? ''); + $remaining = max(0.0, $capacity - (float) ($spool['weight_used'] ?? 0)); + $percent = $remaining / $capacity * 100; + + if ($percent < self::LOW_FILAMENT_PERCENT && ! in_array($id, $alerted, true)) { + $name = trim(($spool['color_name'] ?? '').' '.($spool['material'] ?? 'filament')); + $this->publish('everyone', 'filament', 'Low filament: '.$name, trim(($spool['brand'] ?? '').' · '.round($percent).'% (about '.round($remaining).' g) left', ' ·'), 'warning', route('printers.index')); + $alerted[] = $id; + } elseif ($percent >= self::FILAMENT_REARM_PERCENT) { + $alerted = array_values(array_diff($alerted, [$id])); + } + } + + AlertState::write('filament.low', ['alerted' => array_values($alerted)]); + } + + /** @param 'admins'|'everyone' $audience */ + private function publish(string $audience, string $kind, string $title, string $body, string $level, ?string $url): void + { + $recipients = User::query()->where('disabled', false) + ->when($audience === 'admins', fn ($query) => $query->where('role', 'admin'))->get(); + Notification::send($recipients, new DashboardAlert($kind, $title, $body, $level, $url)); + $this->published++; + } +} diff --git a/app/Services/Bambuddy.php b/app/Services/Bambuddy.php index 07c780b..aaeddcd 100644 --- a/app/Services/Bambuddy.php +++ b/app/Services/Bambuddy.php @@ -44,6 +44,13 @@ class Bambuddy ->filter(fn ($archive): bool => is_array($archive))->values()->all()); } + /** @return array> Spools in the BamBuddy inventory. */ + public function spools(): array + { + return $this->cache->remember('bambuddy.spools.'.md5($this->url()), 300, fn (): array => collect($this->get('inventory/spools')) + ->filter(fn ($spool): bool => is_array($spool))->values()->all()); + } + /** @return array */ public function stats(): array { diff --git a/database/migrations/2026_10_11_195906_create_notifications_table.php b/database/migrations/2026_10_11_195906_create_notifications_table.php new file mode 100644 index 0000000..d738032 --- /dev/null +++ b/database/migrations/2026_10_11_195906_create_notifications_table.php @@ -0,0 +1,31 @@ +uuid('id')->primary(); + $table->string('type'); + $table->morphs('notifiable'); + $table->text('data'); + $table->timestamp('read_at')->nullable(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('notifications'); + } +}; diff --git a/database/migrations/2026_10_11_195907_create_alert_states_table.php b/database/migrations/2026_10_11_195907_create_alert_states_table.php new file mode 100644 index 0000000..038d01f --- /dev/null +++ b/database/migrations/2026_10_11_195907_create_alert_states_table.php @@ -0,0 +1,28 @@ +string('key')->primary(); + $table->json('state'); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('alert_states'); + } +}; diff --git a/resources/js/app.js b/resources/js/app.js index 5415651..e2857d4 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -61,3 +61,5 @@ if (document.querySelector('[data-dashboard-chart]')) { }); }); } + +import './notifications'; diff --git a/resources/js/notifications.js b/resources/js/notifications.js new file mode 100644 index 0000000..6fdd5d0 --- /dev/null +++ b/resources/js/notifications.js @@ -0,0 +1,143 @@ +const root = document.querySelector('[data-notifications]'); +const toastRegion = document.querySelector('[data-toast-region]'); + +const levelStyles = { + info: { bar: 'bg-sky-400', dot: 'bg-sky-400' }, + success: { bar: 'bg-emerald-400', dot: 'bg-emerald-400' }, + warning: { bar: 'bg-amber-400', dot: 'bg-amber-400' }, + danger: { bar: 'bg-rose-500', dot: 'bg-rose-500' }, +}; + +if (root && toastRegion) { + const pollSeconds = 15; + const toggle = root.querySelector('[data-notifications-toggle]'); + const panel = root.querySelector('[data-notifications-panel]'); + const list = root.querySelector('[data-notifications-list]'); + const empty = root.querySelector('[data-notifications-empty]'); + const badge = root.querySelector('[data-notifications-badge]'); + const desktop = root.querySelector('[data-notifications-desktop]'); + const csrf = document.querySelector('meta[name="csrf-token"]')?.content ?? ''; + const known = new Set(); + let firstLoad = true; + + const post = (url) => fetch(url, { + method: 'POST', + headers: { 'X-CSRF-TOKEN': csrf, Accept: 'application/json' }, + credentials: 'same-origin', + }); + + const readUrl = (id) => root.dataset.readUrl.replace('__id__', encodeURIComponent(id)); + const style = (level) => levelStyles[level] ?? levelStyles.info; + + const element = (tag, className, text) => { + const node = document.createElement(tag); + if (className) node.className = className; + if (text !== undefined) node.textContent = text; + return node; + }; + + const openItem = async (item) => { + if (!item.read) { + item.read = true; + await post(readUrl(item.id)).catch(() => {}); + } + if (item.url) window.location.assign(item.url); + else refresh(); + }; + + const renderPanel = (items, unread) => { + list.replaceChildren(...items.map((item) => { + const row = element('li'); + const button = element('button', 'flex w-full gap-3 px-4 py-3 text-left transition hover:bg-slate-800/60 focus-visible:outline focus-visible:outline-2 focus-visible:outline-white'); + button.type = 'button'; + button.append(element('span', `mt-1.5 size-2 shrink-0 rounded-full ${item.read ? 'bg-slate-700' : style(item.level).dot}`)); + const text = element('span', 'min-w-0 flex-1'); + text.append( + element('span', `block text-sm ${item.read ? 'text-slate-300' : 'font-semibold text-white'}`, item.title), + element('span', 'mt-0.5 block text-xs text-slate-400', item.body), + element('span', 'mt-1 block text-[11px] text-slate-500', item.time), + ); + button.append(text); + button.addEventListener('click', () => openItem(item)); + row.append(button); + return row; + })); + empty.hidden = items.length > 0; + badge.hidden = unread === 0; + badge.textContent = unread > 99 ? '99+' : String(unread); + toggle.setAttribute('aria-label', unread ? `Notifications, ${unread} unread` : 'Notifications'); + }; + + const dismiss = (toast) => { + toast.classList.add('opacity-0', 'translate-y-2'); + setTimeout(() => toast.remove(), 200); + }; + + const showToast = (item) => { + const { bar } = style(item.level); + const toast = element('div', 'pointer-events-auto relative flex overflow-hidden rounded-xl border border-slate-700 bg-slate-900 shadow-xl shadow-black/40 transition duration-200'); + toast.setAttribute('role', item.level === 'danger' ? 'alert' : 'status'); + toast.append(element('span', `w-1.5 shrink-0 ${bar}`)); + const body = element('button', 'min-w-0 flex-1 px-4 py-3 text-left focus-visible:outline focus-visible:outline-2 focus-visible:outline-white'); + body.type = 'button'; + body.append(element('span', 'block text-sm font-semibold text-white', item.title), element('span', 'mt-0.5 block text-xs text-slate-300', item.body)); + body.addEventListener('click', () => { dismiss(toast); openItem(item); }); + const close = element('button', 'px-3 text-slate-400 hover:text-white focus-visible:outline focus-visible:outline-2 focus-visible:outline-white', '✕'); + close.type = 'button'; + close.setAttribute('aria-label', 'Dismiss notification'); + close.addEventListener('click', () => dismiss(toast)); + toast.append(body, close); + toastRegion.append(toast); + setTimeout(() => dismiss(toast), item.level === 'danger' ? 15000 : 8000); + }; + + const showDesktop = (item) => { + if (!('Notification' in window) || Notification.permission !== 'granted' || !document.hidden) return; + const note = new Notification(item.title, { body: item.body, tag: item.id }); + note.addEventListener('click', () => { window.focus(); openItem(item); }); + }; + + const refresh = async () => { + try { + const response = await fetch(root.dataset.feedUrl, { headers: { Accept: 'application/json' }, credentials: 'same-origin' }); + if (!response.ok) return; + const { items, unread } = await response.json(); + renderPanel(items, unread); + items.filter((item) => !known.has(item.id)).reverse().forEach((item) => { + known.add(item.id); + if (!firstLoad && !item.read) { + showToast(item); + showDesktop(item); + } + }); + firstLoad = false; + } catch (error) { + // Network blips are expected; the next poll retries. + } + }; + + const setPanel = (open) => { + panel.hidden = !open; + toggle.setAttribute('aria-expanded', String(open)); + }; + + toggle.addEventListener('click', () => setPanel(panel.hidden)); + document.addEventListener('click', (event) => { if (!root.contains(event.target)) setPanel(false); }); + document.addEventListener('keydown', (event) => { if (event.key === 'Escape') setPanel(false); }); + root.querySelector('[data-notifications-read-all]').addEventListener('click', async () => { + await post(root.dataset.readAllUrl).catch(() => {}); + refresh(); + }); + + if ('Notification' in window && Notification.permission === 'default') { + desktop.hidden = false; + root.querySelector('[data-notifications-enable-desktop]').addEventListener('click', async () => { + await Notification.requestPermission(); + desktop.hidden = Notification.permission !== 'default'; + }); + } + + refresh(); + setInterval(() => { if (!document.hidden) refresh(); }, pollSeconds * 1000); + document.addEventListener('visibilitychange', () => { if (!document.hidden) refresh(); }); +} diff --git a/resources/views/layouts/app.blade.php b/resources/views/layouts/app.blade.php index 32e782e..aeab5d0 100644 --- a/resources/views/layouts/app.blade.php +++ b/resources/views/layouts/app.blade.php @@ -36,7 +36,7 @@

Workshop dashboard

@yield('title', 'Overview')

-
@csrf
+
@include('partials.notifications')
@csrf
@if(session('status'))
{{ session('status') }}
@endif diff --git a/resources/views/partials/notifications.blade.php b/resources/views/partials/notifications.blade.php new file mode 100644 index 0000000..e3a2284 --- /dev/null +++ b/resources/views/partials/notifications.blade.php @@ -0,0 +1,18 @@ +
+ + +
+
diff --git a/routes/console.php b/routes/console.php index 833c00b..4a993bc 100644 --- a/routes/console.php +++ b/routes/console.php @@ -4,5 +4,6 @@ use Illuminate\Foundation\DevCommands; use Illuminate\Support\Facades\Schedule; Schedule::command('integrations:refresh')->everyThirtySeconds()->withoutOverlapping(5); +Schedule::command('alerts:scan')->everyThirtySeconds()->withoutOverlapping(5); DevCommands::artisan('schedule:work', 'schedule'); diff --git a/routes/web.php b/routes/web.php index ba5a331..a95693f 100644 --- a/routes/web.php +++ b/routes/web.php @@ -7,6 +7,7 @@ use App\Http\Controllers\DryerController; use App\Http\Controllers\EtsyController; use App\Http\Controllers\EtsySettingsController; use App\Http\Controllers\IntegrationSettingsController; +use App\Http\Controllers\NotificationController; use App\Http\Controllers\Parcel2GoConnectionController; use App\Http\Controllers\PrinterController; use App\Http\Controllers\ShippingController; @@ -22,6 +23,9 @@ Route::middleware('guest')->group(function (): void { Route::middleware(['auth', EnsureAccountActive::class])->group(function (): void { Route::post('/logout', [AuthController::class, 'destroy'])->name('logout'); Route::get('/', DashboardController::class)->name('dashboard'); + Route::get('/notifications', [NotificationController::class, 'index'])->name('notifications.index'); + Route::post('/notifications/read', [NotificationController::class, 'readAll'])->name('notifications.read-all'); + Route::post('/notifications/{notification}/read', [NotificationController::class, 'read'])->name('notifications.read'); Route::get('/printers', BambuddyController::class)->name('printers.index'); Route::delete('/printers/{printer}', [PrinterController::class, 'destroy'])->name('printers.destroy'); Route::get('/shipping', [ShippingController::class, 'index'])->name('shipping'); diff --git a/tests/Feature/NotificationInboxTest.php b/tests/Feature/NotificationInboxTest.php new file mode 100644 index 0000000..e49c785 --- /dev/null +++ b/tests/Feature/NotificationInboxTest.php @@ -0,0 +1,53 @@ +getJson(route('notifications.index'))->assertUnauthorized(); + } + + public function test_user_sees_only_their_notifications_and_can_mark_them_read(): void + { + $user = User::factory()->create(); + $other = User::factory()->create(); + $user->notify(new DashboardAlert('order', 'New Etsy order #1', 'Buyer · GBP 5.00', 'success', route('shipping'))); + $other->notify(new DashboardAlert('order', 'Private', 'Not yours', 'info')); + + $response = $this->actingAs($user)->getJson(route('notifications.index'))->assertOk() + ->assertJsonPath('unread', 1)->assertJsonCount(1, 'items') + ->assertJsonPath('items.0.title', 'New Etsy order #1')->assertJsonPath('items.0.level', 'success'); + + $id = $response->json('items.0.id'); + $this->actingAs($other)->postJson(route('notifications.read', $id))->assertOk(); + $this->assertSame(1, $user->unreadNotifications()->count()); + + $this->actingAs($user)->postJson(route('notifications.read', $id))->assertOk(); + $this->assertSame(0, $user->unreadNotifications()->count()); + } + + public function test_mark_all_read_clears_the_unread_count(): void + { + $user = User::factory()->create(); + $user->notify(new DashboardAlert('print', 'One', 'a')); + $user->notify(new DashboardAlert('print', 'Two', 'b')); + + $this->actingAs($user)->postJson(route('notifications.read-all'))->assertOk(); + $this->actingAs($user)->getJson(route('notifications.index'))->assertJsonPath('unread', 0); + } + + public function test_pages_include_the_notification_bell(): void + { + $this->actingAs(User::factory()->create())->get(route('dashboard'))->assertOk() + ->assertSee('data-notifications-toggle', false)->assertSee('data-toast-region', false); + } +} diff --git a/tests/Feature/NotificationScannerTest.php b/tests/Feature/NotificationScannerTest.php new file mode 100644 index 0000000..d1ec4c5 --- /dev/null +++ b/tests/Feature/NotificationScannerTest.php @@ -0,0 +1,153 @@ +> */ + private array $receipts = []; + + /** @var array */ + private array $live = []; + + /** @var array> */ + private array $spools = []; + + private User $admin; + + private User $member; + + protected function setUp(): void + { + parent::setUp(); + $this->admin = User::factory()->create(['role' => 'admin']); + $this->member = User::factory()->create(['role' => 'user']); + config()->set('services.etsy', ['keystring' => 'key', 'shared_secret' => 'secret']); + config()->set('services.bambuddy', ['url' => 'https://3d.example.test', 'api_key' => 'key']); + EtsyConnection::create(['shop_id' => 42, 'shop_name' => 'HickLab3D', 'access_token' => 'token', 'refresh_token' => 'refresh', 'expires_at' => now()->addHour()]); + Http::preventStrayRequests(); + Http::fake(fn (Request $request) => match (true) { + str_contains($request->url(), '/receipts') => Http::response(['results' => $this->receipts]), + str_ends_with($request->url(), '/api/v1/printers/') => Http::response([['id' => 1, 'name' => 'Printy', 'model' => 'X1C']]), + str_ends_with($request->url(), '/printers/1/status') => Http::response($this->live), + str_contains($request->url(), '/inventory/spools') => Http::response($this->spools), + default => Http::response([], 404), + }); + } + + private function scan(): int + { + Cache::flush(); + + return app(AlertScanner::class)->scan(); + } + + /** @return array */ + private function receipt(int $id, ?int $shipBy = null): array + { + return [ + 'receipt_id' => $id, 'name' => 'Buyer '.$id, 'grandtotal' => ['amount' => 1250, 'divisor' => 100, 'currency_code' => 'GBP'], + 'transactions' => $shipBy ? [['expected_ship_date' => $shipBy]] : [], + ]; + } + + public function test_new_orders_notify_admins_only_and_the_first_scan_is_silent(): void + { + $this->receipts = [$this->receipt(1)]; + $this->assertSame(0, $this->scan()); + + $this->receipts = [$this->receipt(2), $this->receipt(1)]; + $this->assertSame(1, $this->scan()); + $this->assertSame(0, $this->scan()); + + $notification = $this->admin->notifications()->first(); + $this->assertSame('New Etsy order #2', $notification->data['title']); + $this->assertSame('Buyer 2 · GBP 12.50', $notification->data['body']); + $this->assertCount(0, $this->member->notifications); + } + + public function test_orders_due_within_a_day_and_overdue_each_notify_once(): void + { + $this->receipts = [$this->receipt(7, time() + 3600 * 12)]; + $this->assertSame(1, $this->scan()); + $this->assertSame(0, $this->scan()); + $this->assertSame('Order #7 is due to ship within 24 hours', $this->admin->notifications()->first()->data['title']); + + $this->receipts = [$this->receipt(7, time() - 60)]; + $this->assertSame(1, $this->scan()); + $this->assertSame(0, $this->scan()); + $this->assertContains('Order #7 is overdue to ship', $this->admin->notifications->pluck('data.title')->all()); + + $this->receipts = [$this->receipt(8, time() + 3600 * 72)]; + $this->scan(); + $this->assertSame(3, $this->admin->notifications()->count()); + $this->assertContains('New Etsy order #8', $this->admin->fresh()->notifications->pluck('data.title')->all()); + } + + public function test_print_milestones_completion_and_errors_notify_everyone_once(): void + { + $this->live = ['connected' => true, 'state' => 'RUNNING', 'subtask_name' => 'Vase', 'progress' => 30.0, 'remaining_time' => 90, 'hms_errors' => []]; + $this->assertSame(0, $this->scan()); + + $this->live['progress'] = 55.0; + $this->assertSame(1, $this->scan()); + $this->assertSame(0, $this->scan()); + $this->assertSame('Printy · Vase is 50% complete', $this->member->notifications()->first()->data['title']); + + $this->live['progress'] = 80.0; + $this->assertSame(1, $this->scan()); + + $this->live['hms_errors'] = [['description' => 'Filament tangled']]; + $this->assertSame(1, $this->scan()); + $this->assertSame(0, $this->scan()); + + $this->live = ['connected' => true, 'state' => 'FINISH', 'subtask_name' => 'Vase', 'progress' => 100.0, 'hms_errors' => []]; + $this->assertSame(1, $this->scan()); + $this->assertSame(0, $this->scan()); + $this->assertContains('Printy · Vase is 100% complete', $this->admin->notifications->pluck('data.title')->all()); + } + + public function test_failed_prints_notify_as_errors(): void + { + $this->live = ['connected' => true, 'state' => 'RUNNING', 'subtask_name' => 'Vase', 'progress' => 10.0, 'hms_errors' => []]; + $this->scan(); + $this->live['state'] = 'FAILED'; + + $this->assertSame(1, $this->scan()); + $this->assertSame(0, $this->scan()); + $notification = $this->admin->notifications()->first(); + $this->assertSame('danger', $notification->data['level']); + $this->assertSame('Printy · Vase failed', $notification->data['title']); + } + + public function test_low_filament_notifies_once_until_the_spool_is_refilled(): void + { + $this->spools = [ + ['id' => 1, 'color_name' => 'Azure', 'material' => 'ABS', 'brand' => 'Bambu Lab', 'label_weight' => 1000, 'weight_used' => 850, 'archived_at' => null], + ['id' => 2, 'color_name' => 'Black', 'material' => 'PLA', 'label_weight' => 1000, 'weight_used' => 100, 'archived_at' => null], + ['id' => 3, 'color_name' => 'Old', 'material' => 'PLA', 'label_weight' => 1000, 'weight_used' => 990, 'archived_at' => '2026-01-01T00:00:00'], + ]; + + $this->assertSame(1, $this->scan()); + $this->assertSame(0, $this->scan()); + $notification = $this->admin->notifications()->first(); + $this->assertSame('Low filament: Azure ABS', $notification->data['title']); + $this->assertSame('Bambu Lab · 15% (about 150 g) left', $notification->data['body']); + + $this->spools[0]['weight_used'] = 100; + $this->scan(); + $this->spools[0]['weight_used'] = 900; + $this->assertSame(1, $this->scan()); + } +}