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
@@ -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++;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user