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
{