Add notification inbox, toasts and alert scanner for orders, prints and filament
Deploy HickLab3D Dashboard / Deploy to production (push) Successful in 28s

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Hickmeister
2026-10-11 21:02:35 +01:00
co-authored by Claude Sonnet 5.5
parent 04be9802dc
commit ab1fdb961f
17 changed files with 805 additions and 1 deletions
@@ -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()) {
+20
View File
@@ -0,0 +1,20 @@
<?php
namespace App\Console\Commands;
use App\Services\AlertScanner;
use Illuminate\Console\Attributes\Description;
use Illuminate\Console\Attributes\Signature;
use Illuminate\Console\Command;
#[Signature('alerts:scan')]
#[Description('Publish notifications for new orders, print progress, low filament and due shipments')]
class ScanAlerts extends Command
{
public function handle(AlertScanner $scanner): int
{
$this->line($scanner->scan().' notification(s) published');
return self::SUCCESS;
}
}
@@ -0,0 +1,43 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Notifications\DatabaseNotification;
class NotificationController extends Controller
{
public function index(Request $request): JsonResponse
{
$user = $request->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]);
}
}
+40
View File
@@ -0,0 +1,40 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
/**
* Remembers what the alert scanner has already seen so each event notifies once.
*/
class AlertState extends Model
{
public $incrementing = false;
protected $primaryKey = 'key';
protected $keyType = 'string';
protected $fillable = ['key', 'state'];
protected function casts(): array
{
return ['state' => 'array'];
}
/**
* @return array<string, mixed>|null
*/
public static function read(string $key): ?array
{
return static::find($key)?->state;
}
/**
* @param array<string, mixed> $state
*/
public static function write(string $key, array $state): void
{
static::updateOrCreate(['key' => $key], ['state' => $state]);
}
}
+35
View File
@@ -0,0 +1,35 @@
<?php
namespace App\Notifications;
use Illuminate\Notifications\Notification;
class DashboardAlert extends Notification
{
/**
* @param 'info'|'success'|'warning'|'danger' $level
*/
public function __construct(
public string $kind,
public string $title,
public string $body,
public string $level = 'info',
public ?string $url = null,
) {}
/**
* @return array<int, string>
*/
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];
}
}
+225
View File
@@ -0,0 +1,225 @@
<?php
namespace App\Services;
use App\Models\AlertState;
use App\Models\EtsyConnection;
use App\Models\User;
use App\Notifications\DashboardAlert;
use Illuminate\Support\Facades\Notification;
use Throwable;
/**
* Looks at the cached integration data and publishes a notification for each new event.
*/
class AlertScanner
{
private const PRINT_MILESTONES = [25, 50, 75];
private const LOW_FILAMENT_PERCENT = 20;
private const FILAMENT_REARM_PERCENT = 25;
private const DUE_WINDOW_SECONDS = 86400;
private const SEEN_ORDER_LIMIT = 500;
private int $published = 0;
public function __construct(private Etsy $etsy, private Bambuddy $bambuddy, private Display $display) {}
/** Run every detector and return how many notifications were published. */
public function scan(): int
{
$this->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<string, mixed> $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<string, mixed> $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<string, mixed> $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++;
}
}
+7
View File
@@ -44,6 +44,13 @@ class Bambuddy
->filter(fn ($archive): bool => is_array($archive))->values()->all());
}
/** @return array<int, array<string, mixed>> 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<string, mixed> */
public function stats(): array
{
@@ -0,0 +1,31 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('notifications', function (Blueprint $table) {
$table->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');
}
};
@@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('alert_states', function (Blueprint $table) {
$table->string('key')->primary();
$table->json('state');
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('alert_states');
}
};
+2
View File
@@ -61,3 +61,5 @@ if (document.querySelector('[data-dashboard-chart]')) {
});
});
}
import './notifications';
+143
View File
@@ -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(); });
}
+1 -1
View File
@@ -36,7 +36,7 @@
<div class="min-w-0 flex-1 lg:pl-64">
<header class="flex min-h-20 items-center justify-between gap-4 border-b border-slate-800 px-6 lg:px-10">
<div><p class="text-xs font-semibold uppercase tracking-[.2em] text-brand">Workshop dashboard</p><h1 class="text-xl font-bold">@yield('title', 'Overview')</h1></div>
<div class="flex items-center gap-4"><span class="hidden text-sm text-slate-400 sm:inline">{{ auth()->user()->username }}</span><form method="post" action="{{ route('logout') }}">@csrf<button class="rounded-lg border border-slate-700 px-3 py-2 text-sm text-slate-300 hover:border-brand hover:text-white">Sign out</button></form></div>
<div class="flex items-center gap-4">@include('partials.notifications')<span class="hidden text-sm text-slate-400 sm:inline">{{ auth()->user()->username }}</span><form method="post" action="{{ route('logout') }}">@csrf<button class="rounded-lg border border-slate-700 px-3 py-2 text-sm text-slate-300 hover:border-brand hover:text-white">Sign out</button></form></div>
</header>
<main class="flex w-full flex-col gap-7 px-6 py-8 lg:px-10">
@if(session('status'))<div role="status" class="rounded-xl border border-brand/30 bg-brand/10 px-4 py-3 text-sm text-brand">{{ session('status') }}</div>@endif
@@ -0,0 +1,18 @@
<div class="relative" data-notifications data-feed-url="{{ route('notifications.index') }}" data-read-all-url="{{ route('notifications.read-all') }}" data-read-url="{{ route('notifications.read', '__id__') }}">
<button type="button" data-notifications-toggle class="relative grid size-10 place-items-center rounded-lg border border-slate-700 text-slate-300 transition hover:border-brand hover:text-white focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white" aria-label="Notifications" aria-haspopup="true" aria-expanded="false">
<svg class="size-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 8a6 6 0 1 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10.3 21a1.94 1.94 0 0 0 3.4 0"/></svg>
<span data-notifications-badge hidden class="absolute -right-1.5 -top-1.5 grid min-w-5 place-items-center rounded-full bg-brand px-1 text-[11px] font-bold leading-5 text-slate-950"></span>
</button>
<div data-notifications-panel hidden class="absolute right-0 z-40 mt-3 w-[min(24rem,calc(100vw-2rem))] overflow-hidden rounded-2xl border border-slate-700 bg-slate-900 shadow-2xl shadow-black/50">
<div class="flex items-center justify-between gap-3 border-b border-slate-800 px-4 py-3">
<h2 class="text-sm font-semibold">Notifications</h2>
<button type="button" data-notifications-read-all class="text-xs font-semibold text-brand hover:underline">Mark all read</button>
</div>
<ul data-notifications-list class="max-h-96 divide-y divide-slate-800 overflow-y-auto"></ul>
<p data-notifications-empty class="px-4 py-8 text-center text-sm text-slate-400">You are all caught up.</p>
<div data-notifications-desktop hidden class="border-t border-slate-800 px-4 py-3">
<button type="button" data-notifications-enable-desktop class="text-xs font-semibold text-brand hover:underline">Enable desktop notifications</button>
</div>
</div>
</div>
<div data-toast-region class="pointer-events-none fixed bottom-4 right-4 z-50 flex w-[min(22rem,calc(100vw-2rem))] flex-col gap-3" aria-live="polite" aria-atomic="false"></div>
+1
View File
@@ -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');
+4
View File
@@ -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');
+53
View File
@@ -0,0 +1,53 @@
<?php
namespace Tests\Feature;
use App\Models\User;
use App\Notifications\DashboardAlert;
use Illuminate\Foundation\Testing\LazilyRefreshDatabase;
use Tests\TestCase;
class NotificationInboxTest extends TestCase
{
use LazilyRefreshDatabase;
public function test_guests_cannot_read_notifications(): void
{
$this->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);
}
}
+153
View File
@@ -0,0 +1,153 @@
<?php
namespace Tests\Feature;
use App\Models\EtsyConnection;
use App\Models\User;
use App\Services\AlertScanner;
use Illuminate\Foundation\Testing\LazilyRefreshDatabase;
use Illuminate\Http\Client\Request;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Tests\TestCase;
class NotificationScannerTest extends TestCase
{
use LazilyRefreshDatabase;
/** @var array<int, array<string, mixed>> */
private array $receipts = [];
/** @var array<string, mixed> */
private array $live = [];
/** @var array<int, array<string, mixed>> */
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<string, mixed> */
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());
}
}