From 7b6330337be1821d9e7995647e356cd71857c11c Mon Sep 17 00:00:00 2001 From: jakub-przepiora Date: Tue, 28 Jul 2026 08:20:12 +0200 Subject: [PATCH] fix(planner): stop check-updates poll on 401/419; fix(access): gate customers/priority under the Orders tab Two errors surfaced by PostHog http_error telemetry: - 401 /admin/schedule/check-updates (385 events / 4 users): the planner poll retried forever after session expiry. Both pollers now stop on 401/419. - 403 /admin/customers + /admin/priority-rules: unmapped to any tab -> admin-only, but shown under Orders in the nav. Now resolve to the orders tab. --- CHANGELOG.md | 2 ++ backend/app/Support/TabRegistry.php | 2 +- .../resources/js/Pages/admin/schedule/Planner.jsx | 6 +++++- backend/resources/js/components/LiveRefresh.jsx | 7 ++++++- backend/tests/Feature/Web/TabAccessTest.php | 15 +++++++++++++++ 5 files changed, 29 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f32d6f88..75c46669 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,8 @@ Format based on [Keep a Changelog](https://keepachangelog.com/). - **Customers — master data for work orders** *(phase 1)*: work orders can now be attached to a **customer**. A new **Admin → Orders → Customers** section provides full CRUD (list, create, edit, soft-delete, activate/deactivate) for customers carrying a **name**, optional **code**, a loyalty **tier** (bronze / silver / gold / VIP), a manual **payment score** (0–100), and app-maintained **total orders** / **total revenue** aggregates plus free-text notes. Work orders gain a nullable **customer** selector on the admin and supervisor create/edit forms; the customer name shows on the work-order list and detail (with a tier badge) and in the production **planner tooltip**. Deleting a customer never removes its work orders. Customers are soft-deletable (Trash + audit) and live-synced via a new `customers` Electric shape. New `customers` table, `work_orders.customer_id` FK (null-on-delete), `App\Models\Customer`, `App\Enums\Tier`, `CustomerController`. This is the foundation for automatic priority scoring (phase 2). ### Fixed +- **Planner poll hammered the server with 401s after a session expired** *(telemetry: `http_error` was ~57% one endpoint)*: the production planner short-polls `/admin/schedule/check-updates` every 5-10s to sync across tabs. When the session expired on a left-open planner tab, each poll returned **401** and the client silently retried forever, quietly generating hundreds of error events per stale tab. Both pollers (`LiveRefresh` and the planner's tracking poll) now **stop on a 401/419** (session gone) instead of retrying indefinitely — a real navigation still bounces the user to login. +- **Customers and Priority Settings links 403'd for non-admin roles** *(telemetry)*: `/admin/customers` and `/admin/priority-rules` sat outside every `TabRegistry` tab, so `TabAccessMiddleware` treated them as **Admin-only (403)** — yet the sidebar showed them under the **Orders** group to anyone holding the `orders` tab, so a Supervisor/Operator with Orders access saw the links and got a 403 on click. They now resolve to the **Orders tab**, matching where the nav places them, so access and visibility agree. - **New MQTT connections weren't picked up until a manual listener restart** *([#174](https://github.com/Mes-Open/OpenMes/issues/174))*: `mqtt:listen` was a single-connection process pinned to one `--connection=` at startup, so creating a connection in **Admin → Connectivity → MQTT** left it stuck at `disconnected` — the DB row existed but nothing was subscribed against it until you restarted the listener with that ID. It is now a **supervisor**: one process services **all** active MQTT connections at once (non-blocking `loopOnce()` per client) and **reconciles against the database every few seconds** — a newly created/activated connection is connected + subscribed automatically, a deactivated/deleted one is disconnected, and a connection whose broker settings or topics changed is reconnected with the new config. The `mqtt-listener` container now runs `mqtt:listen` with no pinned ID by default (pass `--connection=` to supervise just one). - **MQTT connection detail — Live Message Log went blank after the first message** *([#174](https://github.com/Mes-Open/OpenMes/issues/174))*: the message log's time formatter was a local `formatTime` that shadowed the imported i18n helper and **recursed into itself**, so the panel rendered fine while empty ("Waiting for messages…") but blew the call stack the moment a message arrived — blanking the log. Renamed the local helper so it calls the imported formatter instead of itself. diff --git a/backend/app/Support/TabRegistry.php b/backend/app/Support/TabRegistry.php index 5f0b7a9d..5faa5110 100644 --- a/backend/app/Support/TabRegistry.php +++ b/backend/app/Support/TabRegistry.php @@ -20,7 +20,7 @@ class TabRegistry 'dashboard' => ['label' => 'Dashboard', 'prefixes' => ['/admin/dashboard']], 'alerts' => ['label' => 'Alerts', 'prefixes' => ['/admin/alerts']], 'schedule' => ['label' => 'Schedule', 'prefixes' => ['/admin/schedule']], - 'orders' => ['label' => 'Orders', 'prefixes' => ['/admin/work-orders', '/admin/csv-import']], + 'orders' => ['label' => 'Orders', 'prefixes' => ['/admin/work-orders', '/admin/customers', '/admin/priority-rules', '/admin/csv-import']], 'production' => ['label' => 'Production', 'prefixes' => [ '/admin/product-types', '/admin/lot-sequences', '/admin/lines', '/admin/line-statuses', '/admin/view-templates', '/admin/shifts', diff --git a/backend/resources/js/Pages/admin/schedule/Planner.jsx b/backend/resources/js/Pages/admin/schedule/Planner.jsx index 032ecf94..1646f230 100644 --- a/backend/resources/js/Pages/admin/schedule/Planner.jsx +++ b/backend/resources/js/Pages/admin/schedule/Planner.jsx @@ -281,16 +281,20 @@ export default function Planner() { setTrackingData(null); if (!trackId) return undefined; let alive = true; + let t = null; const fetchIt = async () => { try { const r = await apiGet(`/admin/schedule/check-updates?track=${trackId}`); + // Session expired → stop this 5s poll instead of 401ing forever + // on a left-open planner tab (a real action will redirect to login). + if (r.status === 401 || r.status === 419) { clearInterval(t); return; } if (!r.ok) return; const d = await r.json(); if (alive && d.tracked_order) setTrackingData(d.tracked_order); } catch { /* silent */ } }; fetchIt(); - const t = setInterval(fetchIt, 5000); + t = setInterval(fetchIt, 5000); return () => { alive = false; clearInterval(t); }; }, [trackId]); diff --git a/backend/resources/js/components/LiveRefresh.jsx b/backend/resources/js/components/LiveRefresh.jsx index 70424e6b..ed7be5ef 100644 --- a/backend/resources/js/components/LiveRefresh.jsx +++ b/backend/resources/js/components/LiveRefresh.jsx @@ -33,11 +33,16 @@ export default function LiveRefresh({ useEffect(() => { if (!enabled || !pollUrl) return undefined; + let t = null; const tick = async () => { try { const r = await fetch(pollUrl, { headers: { Accept: 'application/json', 'X-Requested-With': 'XMLHttpRequest' }, }); + // Session gone (expired / logged out in another tab). Keep polling + // and every tick 401s forever — a left-open tab quietly generates + // hundreds of errors. Stop; a real navigation will bounce to login. + if (r.status === 401 || r.status === 419) { clearInterval(t); return; } if (!r.ok) return; const d = await r.json(); if (!d.last_updated) return; @@ -49,7 +54,7 @@ export default function LiveRefresh({ } } catch { /* silent — try again next tick */ } }; - const t = setInterval(tick, intervalMs); + t = setInterval(tick, intervalMs); return () => clearInterval(t); }, [enabled, pollUrl, intervalMs]); diff --git a/backend/tests/Feature/Web/TabAccessTest.php b/backend/tests/Feature/Web/TabAccessTest.php index de34cec6..9d48325d 100644 --- a/backend/tests/Feature/Web/TabAccessTest.php +++ b/backend/tests/Feature/Web/TabAccessTest.php @@ -3,6 +3,7 @@ namespace Tests\Feature\Web; use App\Models\User; +use App\Support\TabRegistry; use Illuminate\Foundation\Testing\RefreshDatabase; use Inertia\Testing\AssertableInertia; use Spatie\Permission\Models\Role; @@ -48,6 +49,20 @@ public function test_supervisor_without_grant_is_forbidden(): void $this->actingAs($this->supervisor)->get('/admin/users')->assertForbidden(); } + public function test_customers_and_priority_rules_are_reachable_with_the_orders_tab(): void + { + // Regression: these sat outside every tab prefix, so tab.access treated + // them as Admin-only (403) while the sidebar still showed them under the + // Orders group to any tab:orders holder — a link that 403s on click. They + // now resolve to the Orders tab, matching where the nav puts them. + $this->assertSame('orders', TabRegistry::tabForPath('/admin/customers')); + $this->assertSame('orders', TabRegistry::tabForPath('/admin/priority-rules')); + + // Supervisor holds tab:orders by default → both are now reachable (was 403). + $this->actingAs($this->supervisor)->get('/admin/customers')->assertOk(); + $this->actingAs($this->supervisor)->get('/admin/priority-rules')->assertOk(); + } + public function test_granting_a_tab_lets_the_role_in(): void { // hr is not a Supervisor default → granting it opens HR pages.