Add notification inbox, toasts and alert scanner for orders, prints and filament
Deploy HickLab3D Dashboard / Deploy to production (push) Successful in 28s
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:
co-authored by
Claude Sonnet 5.5
parent
04be9802dc
commit
ab1fdb961f
@@ -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()) {
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
@@ -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++;
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -61,3 +61,5 @@ if (document.querySelector('[data-dashboard-chart]')) {
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
import './notifications';
|
||||
|
||||
@@ -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(); });
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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');
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user