13 Commits

Author SHA1 Message Date
julian 65328b8c11 feat(anpr): subscriber-entry bridge + admin disable toggle
CI / check (push) Failing after 15s
Wire the lane camera's vehicle event into the gated subscription flow: on a
vehicle/active push from an opt-in (config.anpr) camera, AnprBridge pulls a fresh
snapshot, runs ANPR, applies a stricter entry confidence floor, debounces, and —
matching the plate to a subscription BEFORE emitting — emits a kind:"plate" read.
The existing ReadDispatcher -> SubscriptionFlow then signs the entry/exit and opens
the barrier. A plate is never the sole authority: it routes through the same gate
(active/window/blocklist/car-count) as any credential. Fail-soft, fire-and-forget,
subscriber-only by construction. Field-verified end to end (plate AA504LX opened the
entry barrier and appended a signed vehicle_entry).

Add an admin master switch (site_config.anpr_entry_enabled, default ON) in Site
Settings that disables ONLY the barrier-driving bridge; advisory snapshot-ANPR and
lane busy/free are unaffected. Read live per event, so toggling takes effect with no
restart. Migration 0013 (additive ALTER ADD COLUMN, default 1).

- New: apps/server/src/anpr-entry.ts (AnprBridge) + tests (9)
- hikvision-alarm.ts hands vehicle detections to the bridge (fire-and-forget) + wiring tests (3)
- server.ts reorders the read flows above the hik-alarm registration
- snapshot.ts exports buildCamera for reuse
- env: VISION_ENTRY_MIN_CONFIDENCE (0.85), ANPR_DEBOUNCE_MS (12000)
- site route + SiteSettings checkbox + i18n (sq/en parity)
- wiki: lane-presence-and-anpr-entry / lpr-camera / index / log -> BUILT

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-22 19:49:18 +02:00
julian 411572511d docs(camera): lane presence + ANPR subscriber-entry bridge design
Captures this session's back-and-forth as a new concept page
[[lane-presence-and-anpr-entry]] and cross-links it:

- BUILT: advisory lane busy/free booth lights (LaneStatus + WS), with the
  measured camera limits behind the 30s timeout (no leave signal; movement-
  driven re-fire; notificationRecurrence locked to "beginning" — ISAPI flip
  silently reverts).
- PLANNED: the ANPR "bridge" — explicitly a small apps/server HANDLER (~40
  lines), NOT a new service/container. On a camera vehicle event: snapshot ->
  ANPR -> high-confidence match -> debounce -> emitRead{kind:"plate"}, then
  the existing subscription match/dispatch/gate admits the subscriber. Both
  directions, opt-in (config.anpr), plate never the sole authority.
- Records the decisions (high confidence floor, debounce-for-correctness)
  and the REJECTED ideas (continuous livestream / per-car queue tracking /
  make-model) with why, plus the open hardware question (booth-PC test).

Updates subscription.md (plate matching is built; the live source is this
bridge) and lpr-camera.md (the two consumers of the vehicle event).

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-22 19:12:49 +02:00
julian a2bdf99db2 fix(lane-status): TTL 5s -> 30s after measuring the real re-fire pattern
Controlled in/out test on the camera: the `active` re-fire rate is
MOVEMENT-driven, not steady — ~1-3s apart while the car moves, but up to
~15-25s when it sits MOTIONLESS in the zone. A 5s TTL would flicker a
parked car free; the TTL must exceed the still-car gap. The camera has
~no dwell lag (goes silent within ~1s of the car leaving — measured: last
event 16:15:17 vs car-left ~16:15:30), so 30s keeps a motionless car busy
while clearing promptly after departure. This also confirms vision-based
tracking isn't warranted: the camera's leave signal is already tight.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-22 18:16:42 +02:00
julian 89542d4ab6 fix(lane-status): drop busy TTL 90s -> 5s (camera re-fires ~1s)
Measured the real re-fire rate on the camera: while a vehicle is in the
zone it POSTs `active` about every ~1 second (not the ~30-80s I'd guessed).
The camera sends no leave signal, so "free" is timeout-driven — but with a
~1s re-fire, 90s made the lane stay red for a minute and a half after the
car left. 5s of silence reliably means the car is gone; the light now
clears within seconds. Still override-able via LANE_BUSY_TTL_MS.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-22 17:58:25 +02:00
julian e0b9442acc feat(booth): live lane busy/free barrier lights from camera vehicle detection
A Hikvision vehicle detection (eventType=VMD, targetType=vehicle) on a
camera bound to entry/exit now marks that lane "busy" and shows it as a
barrier light beside the scan input on the booth (green=free, red=busy).
Advisory only — it gates nothing (never blocks a ticket or opens a barrier).

- Parse eventState (active/inactive) from the Hik payload.
- LaneStatus tracker: a vehicle `active` event marks the camera's bound lane
  busy + arms an auto-clear timer. This camera class sends no leave/`inactive`
  signal, so "free" is timeout-driven (LANE_BUSY_TTL_MS, default 90s; the
  camera re-fires `active` while a car sits there, refreshing the timer). A
  "both"-direction camera marks both lanes.
- Push lane-status over the existing booth WS (+ in the hello snapshot);
  live-store holds { entry, exit }; two BarrierLight icons render it.
- i18n booth.laneEntry/laneExit (sq + en).

Tests: lane-status.test.ts (7 — busy/free, TTL auto-clear, timer re-arm,
no re-emit while busy, both/exit direction, unknown device). server 120/120;
web + server build/lint green.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-22 17:42:54 +02:00
julian 6f4e390c05 feat(dev): bind Vite to 0.0.0.0 for LAN access (phone over wifi)
Vite had no host set (localhost only). Bind 0.0.0.0 so the dev booth UI is
reachable from other LAN devices at http://<host-lan-ip>:5173. The SPA
already uses relative paths + the page origin for API and the live WS, so
no app code changes — but loading from a non-localhost origin means the
/api/ws handshake's Origin is the LAN address, which the backend's
WS_ALLOWED_ORIGINS must include (documented in .env.example; the host's own
.env is gitignored).

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-22 17:34:35 +02:00
julian df6a1ca63a docs(camera): correct the "dead camera" conclusion — root cause was undrawn detection area
The Hik DS-2CD1043G2-LIU was NOT defective. An earlier wiki entry wrongly
concluded it needed RMA (dead event engine) based on a silent alertStream
+ diskfull/EventScribe:except + dead RTC surviving a full factory reset.

Real cause: no detection AREA was drawn on the frame. With no region, the
camera detects nothing -> generates no event -> posts nothing. The instant
an area was drawn, the first vehicle produced a clean POST.

- Flag "draw the detection area" as the FIRST thing to check.
- Document the confirmed real payload: multipart/form-data (MoveDetection.xml),
  EventNotificationAlert with eventType=VMD, eventState=active,
  targetType=vehicle (vehicle/human classified on-device), targetRect bbox.
  Note the dateTime is garbage (dead RTC) -> use our own receive time.
- Reframe the SSH diagnostics: diskfull/EventScribe/RTC are RED HERRINGS,
  not proof of a dead camera; don't escalate to hardware fault while a basic
  config precondition is unmet.
- Append a log correction (append-only) superseding the earlier conclusion.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-22 16:53:22 +02:00
julian 547061edf9 docs(camera): Hik event-push gotchas + dead-camera diagnostic method
Captures the hard-won findings from the field session: the WSL source-IP
rewrite + skipSourceIpCheck fix, the boolean-as-string setup bug, the
unreliable "Test" button, the latching httpBroken flag, and Notify-
Surveillance-Center vs HTTP-Alarm-Server.

Adds a "diagnose a non-pushing camera from its OWN state" runbook
(alertStream heartbeat silence, SSH showStatus EventScribe:except, dmesg
RTC/UBIFS, netstat outbound watch) and documents the verified-dead
DS-2CD1043G2-LIU unit (defective event engine, survives factory reset ->
RMA), with the pull+vision fallback.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-22 12:50:44 +02:00
julian b7300ec080 fix(hik-alarm): listen on all methods + skippable source-IP guard (WSL)
Diagnosed why no camera push ever landed: (1) the route only registered
POST/GET, so a probe with another method got a generic 404 the camera
reads as "service available" while our handler never ran; (2) more
fundamentally, WSL mirrored mode REWRITES the inbound source IP to the
host's own address (10.0.10.203), so the camera's real IP (10.0.10.12)
never survives and the source-IP guard rejected every push as a mismatch.

- Register the event route on POST/GET/PUT/PATCH/DELETE/OPTIONS (HEAD comes
  with GET) so ANYTHING hitting the path reaches the handler and is recorded.
- Log + store the HTTP method of each hit; log every hit on arrival, before
  any guard, so even a rejected probe is visible immediately.
- Add per-device skipSourceIpCheck (a Setup checkbox) to bypass the
  source-IP guard where the network rewrites the source (WSL). Digest auth +
  the signed ledger remain the real guards.

Tests: hik-alarm 10 (skip-IP accept + method capture). server green;
web build green (new checkbox renderer + this field).

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-22 11:02:17 +02:00
julian 461275521d fix(setup): render boolean config fields as a checkbox (not a text box)
The generic config-field loop had no boolean branch, so a type:"boolean"
field (e.g. the camera's alarmPushEnabled) fell through to a TEXT input and
saved the STRING "true" instead of a real boolean. Downstream checks use
=== true, so the feature read as disabled even when the admin ticked it.

- Web: render type:"boolean" config fields as a real checkbox; store/merge
  a true/false boolean (and persist false on edit so toggling off sticks);
  normalize a legacy string "true"/"false" on load.
- Server: isOn() coerces the flag when reading config (accepts true/"true"/
  1/"yes"/"on") so an existing row saved as the string "true" still works
  without a re-save, and no other boolean field hits the same trap.

Tests: hik-alarm accepts string "true" for alarmPushEnabled. server
112/112; web typecheck + build green.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-22 10:39:10 +02:00
julian 3db8f517d3 feat(hik-alarm): record rejected pushes + a read endpoint to see arrivals
Debugging "is the camera event coming or not?" was painful: a rejected
push only logged a warning and recorded nothing, so "no event" was
ambiguous (never sent vs sent-and-refused), and the only durable record
was an unreadable device_events row.

- Record EVERY push, accepted or rejected: accepted -> kind:"alarm",
  rejected -> kind:"alarm-rejected" with the precise reason (unknown
  device / not-hikvision / push-disabled / source-IP mismatch / digest
  fail). The 404 body now also returns the reason.
- New GET /api/devices/hikvision/alarms (device:read): the recent pushes
  newest-first as JSON (accepted+rejected, with ip/reason/eventType/
  target/plate/rawHead) so you can SEE arrivals in the browser instead of
  grepping the dev log or querying SQLite.

Tests: hikvision-alarm.test.ts now 8 (rejection-recorded + read-endpoint
list + gating). server 111/111; build+lint green.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-22 10:28:09 +02:00
julian 6133923094 feat(camera): Hikvision Alarm Server event-push ingress (discovery-first)
Newer Hik firmware can PUSH events to us: Event -> Smart/VCA with
"Detection Target: Human/Vehicle" + Notify Surveillance Center + Alarm
Settings -> Alarm Server makes the camera HTTP-POST an
EventNotificationAlert on each detection.

- New POST /api/devices/hikvision/:deviceId/event (routes/hikvision-alarm.ts):
  same machine-push pattern as the Dingtian Input Link — source-IP guarded
  + optional HTTP Digest, not behind the SPA cookie/CSRF.
- Discovery-first / permissive: a wildcard content-type parser accepts ANY
  body as raw bytes (event XML, multipart+JPEG, or JSON — Hik varies by
  firmware), records it verbatim as a kind:"alarm" device_event, and
  best-effort extracts eventType/target/plate/dateTime/channelID for the
  summary + a loud log line. The point is to SEE exactly what a camera
  sends before wiring it further.
- hikvision driver gains alarmPushEnabled + pushUser/pushPassword config and
  pushesToBackend:true (setup offers the backend push IP).
- NOT yet a barrier trigger / DeviceReadEvent — records only. A plate read
  is advisory, never the sole reason a barrier opens; the read-bus/ANPR
  wiring is a deliberate next step once the real payload is known.

Tests: hikvision-alarm.test.ts (6: vehicle XML summary, ANPR plate, raw
JSON, wrong-IP 404, disabled 404, unknown-device 404). server 109/109;
build+lint 14/14. Wiki: lpr-camera.md + log.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-22 10:02:35 +02:00
julian 7680d9a0ed feat(recycle-bin): soft delete + restore for master data
Accidental admin deletes of users/roles/subscriptions/plans/tariffs were
hard and unrecoverable. Now they soft-delete into a recycle bin.

Schema (migration 0012): nullable deleted_at + deleted_by on users, roles,
subscriptions, subscription_plans, tariffs. Additive ADD COLUMN; verified
against a copy of the live DB.

Backend: each resource's DELETE route STAMPS instead of removing; every
catalog list filters deleted_at IS NULL. New recycle-bin module + routes
(GET /api/recycle-bin, POST .../restore, DELETE .../:id purge) gated on a
new recyclebin:read/update/delete permission. A 6-hourly + startup sweep
auto-purges items older than RECYCLE_BIN_RETENTION_DAYS (default 30; 0 =
forever).

Invariants: soft-deleted users can't log in (login rejects deleted_at;
no-lockout counts live admins only); a soft-deleted subscription doesn't
open the barrier; plans are versioned so a delete stamps all versions of
the plan_id (bin shows one item); username/role-name UNIQUE spans deleted
rows so reuse returns a clear 409 pointing at the bin; restore doesn't
auto-cascade a dangling role (guard resolves missing role to empty perms).
The signed append-only ledger is OUT of scope (no delete path).

Web: a Recycle bin tab under Setup (RecycleBin.tsx) with Restore/Purge +
purge confirm; api client + i18n (sq + en parity).

Tests: recycle-bin.test.ts (9 unit) + recycle-bin-routes.test.ts (4
integration: delete -> can't-login -> restore -> login, purge, gating,
409 reuse). server 103/103; build+lint+test 19/19.

Wiki: new concepts/soft-delete.md; local-jwt-auth + index + log updated.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-22 09:33:54 +02:00
49 changed files with 2875 additions and 71 deletions
+15 -1
View File
@@ -28,6 +28,11 @@ EVENT_SIGNING_KEY=
# http://localhost MUST set this (the dev .env does). Leave unset in any TLS deploy.
# COOKIE_SECURE=0
# Recycle bin retention: a soft-deleted user/role/subscription/plan/tariff is auto-purged
# this many days after deletion (a 6-hourly sweep). Default 30. Set 0 to keep deleted
# items forever (manual purge only). See wiki/concepts/soft-delete.md.
# RECYCLE_BIN_RETENTION_DAYS=30
# First admin (seed once): pnpm --filter @parking/server seed-admin
# ADMIN_USER=admin
# ADMIN_PASS=
@@ -37,6 +42,9 @@ EVENT_SIGNING_KEY=
# The Tauri DESKTOP shell loads from tauri://localhost (Linux may also send
# http://tauri.localhost), which is NOT same-origin with the backend — add both
# so the desktop app's live feed connects. See apps/desktop.
# To open the dev SPA from another LAN device (phone over wifi), Vite must bind
# 0.0.0.0 (vite.config.ts) AND the host's LAN origin must be listed here, e.g.
# http://10.0.10.203:5173 — the WS handshake's Origin is that LAN address.
WS_ALLOWED_ORIGINS=http://localhost:5173,tauri://localhost,http://tauri.localhost
# Vision / ANPR (optional) -------------------------------------------------
@@ -48,4 +56,10 @@ WS_ALLOWED_ORIGINS=http://localhost:5173,tauri://localhost,http://tauri.localhos
# VISION_ENABLED=1 # master switch — nothing runs without it
# VISION_URL=http://127.0.0.1:8089 # must match apps/vision VISION_HOST:VISION_PORT
# VISION_TIMEOUT_MS=1500 # per-request cap so a slow call can't hang the lane
# VISION_MIN_CONFIDENCE=0.5 # confidence floor; keep in sync with the service
# VISION_MIN_CONFIDENCE=0.5 # advisory confidence floor; keep in sync with the service
#
# ANPR subscriber-entry bridge (anpr-entry.ts): a subscriber's plate, read off a lane
# camera's vehicle detection, admits them through the gated SubscriptionFlow. Opt-in per
# camera (the camera's config.anpr checkbox in Setup); the camera must be BOUND to a relay.
# VISION_ENTRY_MIN_CONFIDENCE=0.85 # stricter floor for a BARRIER-driving read (near-miss → falls back to card/QR)
# ANPR_DEBOUNCE_MS=12000 # same plate/camera within this window = ONE presentation (camera re-fires ~1Hz)
+191
View File
@@ -0,0 +1,191 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { randomUUID } from "node:crypto";
import { devices, deviceEvents as deviceEventsTable, eq, siteConfig, type Db } from "@parking/db";
import { createTestDb } from "@parking/db/testing";
import { deviceEvents, type DeviceReadEvent } from "./device-events.js";
import { silentLogger } from "./test-helpers.js";
import type { VisionClient, VisionResult } from "./vision-client.js";
import type { SubscriptionFlow, SubscriptionMatch } from "./subscription-flow.js";
// The ANPR bridge: a camera vehicle detection → (opt-in) snapshot → plate → MATCH a
// subscriber → emit a plate read. We mock the camera build (buildCamera) so no real
// snapshot HTTP is made, and pass fake Vision/Subscription so the test is the bridge's
// own logic only. See anpr-entry.ts.
// Mock buildCamera so the bridge gets a fake camera whose captureSnapshot is a stub
// (no registry, no network). The factory returns a fresh shot each call.
const captureSnapshot = vi.fn(async () => ({ bytes: Buffer.from("jpg"), contentType: "image/jpeg" }));
vi.mock("./snapshot.js", () => ({
buildCamera: () => ({ captureSnapshot }),
}));
// Import AFTER the mock is registered.
const { AnprBridge } = await import("./anpr-entry.js");
let db: Db;
beforeEach(() => {
({ db } = createTestDb());
captureSnapshot.mockClear();
delete process.env.VISION_ENTRY_MIN_CONFIDENCE;
delete process.env.ANPR_DEBOUNCE_MS;
});
afterEach(() => {
vi.restoreAllMocks();
});
/** A camera bound to an entry relay; `anpr` toggles the opt-in flag. */
function seedCamera(opts: { anpr?: boolean } = {}): string {
const controllerId = randomUUID();
db.insert(devices).values({
id: controllerId,
category: "access",
driverId: "dingtian",
config: { host: "10.0.0.5", relays: [{ relay: 1, direction: "entry" }] },
enabled: true,
}).run();
const camId = randomUUID();
db.insert(devices).values({
id: camId,
category: "camera",
driverId: "hikvision",
config: { host: "10.0.0.9", controllerId, relay: 1, ...(opts.anpr ? { anpr: true } : {}) },
enabled: true,
}).run();
return camId;
}
/** A fake VisionClient: enabled, returning a chosen plate/confidence (or null). */
function fakeVision(opts: { enabled?: boolean; plate?: string; confidence?: number } = {}): VisionClient {
const enabled = opts.enabled ?? true;
const result: VisionResult | null =
opts.plate == null
? null
: {
plate: { text: opts.plate, confidence: opts.confidence ?? 0.99 },
plates: [],
lowConfidence: false,
modelVersion: "test",
tookMs: 1,
};
return {
enabled,
analyze: vi.fn(async () => (enabled ? result : null)),
} as unknown as VisionClient;
}
/** A fake SubscriptionFlow: only `match()` is called by the bridge. */
function fakeSubFlow(match: SubscriptionMatch | null): SubscriptionFlow {
return { match: vi.fn(() => match) } as unknown as SubscriptionFlow;
}
const SUB_MATCH: SubscriptionMatch = { subscriptionId: "sub-1", carKey: "AA111BB", via: "plate" };
/** Capture read events emitted during `fn` (async). */
async function captureReads(fn: () => Promise<void>): Promise<DeviceReadEvent[]> {
const got: DeviceReadEvent[] = [];
const off = deviceEvents.onRead((e) => got.push(e));
try {
await fn();
} finally {
off();
}
return got;
}
describe("AnprBridge", () => {
it("does nothing for an opt-OUT camera (no anpr flag) — no analyze, no read", async () => {
const cam = seedCamera({ anpr: false });
const vision = fakeVision({ plate: "AA111BB" });
const bridge = new AnprBridge(db, vision, fakeSubFlow(SUB_MATCH), silentLogger());
const reads = await captureReads(() => bridge.onVehicleDetected(cam));
expect(reads).toEqual([]);
expect(vision.analyze).not.toHaveBeenCalled();
expect(captureSnapshot).not.toHaveBeenCalled();
});
it("emits a plate read (upper-cased) for a high-confidence SUBSCRIBER plate", async () => {
const cam = seedCamera({ anpr: true });
const vision = fakeVision({ plate: " aa111bb ", confidence: 0.97 });
const bridge = new AnprBridge(db, vision, fakeSubFlow(SUB_MATCH), silentLogger());
const reads = await captureReads(() => bridge.onVehicleDetected(cam));
expect(reads).toHaveLength(1);
expect(reads[0]).toMatchObject({ deviceId: cam, value: "AA111BB", kind: "plate", driverId: "hikvision" });
});
it("ignores a plate below the entry confidence floor", async () => {
const cam = seedCamera({ anpr: true });
const vision = fakeVision({ plate: "AA111BB", confidence: 0.6 }); // < default 0.85
const bridge = new AnprBridge(db, vision, fakeSubFlow(SUB_MATCH), silentLogger());
const reads = await captureReads(() => bridge.onVehicleDetected(cam));
expect(reads).toEqual([]);
});
it("does NOT emit for a plate matching no subscription — records an advisory anpr-skip", async () => {
const cam = seedCamera({ anpr: true });
const vision = fakeVision({ plate: "ZZ999ZZ", confidence: 0.97 });
const bridge = new AnprBridge(db, vision, fakeSubFlow(null), silentLogger());
const reads = await captureReads(() => bridge.onVehicleDetected(cam));
expect(reads).toEqual([]);
const skips = db.select().from(deviceEventsTable).where(eq(deviceEventsTable.kind, "anpr-skip")).all();
expect(skips).toHaveLength(1);
expect((skips[0].detail as { plate?: string }).plate).toBe("ZZ999ZZ");
});
it("debounces: two vehicle events within the window analyze/emit at most once", async () => {
const cam = seedCamera({ anpr: true });
const vision = fakeVision({ plate: "AA111BB", confidence: 0.97 });
const bridge = new AnprBridge(db, vision, fakeSubFlow(SUB_MATCH), silentLogger());
const reads = await captureReads(async () => {
await bridge.onVehicleDetected(cam);
await bridge.onVehicleDetected(cam); // within the 12s window → suppressed
});
expect(reads).toHaveLength(1);
expect(captureSnapshot).toHaveBeenCalledTimes(1); // 2nd was gated before the snapshot
});
it("is a no-op (no throw) when vision is disabled or reads nothing", async () => {
const cam = seedCamera({ anpr: true });
const disabled = new AnprBridge(db, fakeVision({ enabled: false, plate: "AA111BB" }), fakeSubFlow(SUB_MATCH), silentLogger());
const noPlate = new AnprBridge(db, fakeVision({ plate: undefined }), fakeSubFlow(SUB_MATCH), silentLogger());
const reads = await captureReads(async () => {
await disabled.onVehicleDetected(cam);
await noPlate.onVehicleDetected(cam);
});
expect(reads).toEqual([]);
});
it("never throws on an unknown device id", async () => {
const bridge = new AnprBridge(db, fakeVision({ plate: "AA111BB" }), fakeSubFlow(SUB_MATCH), silentLogger());
await expect(bridge.onVehicleDetected("nope")).resolves.toBeUndefined();
});
it("does NOTHING when the admin has disabled the bridge (site_config.anprEntryEnabled = false)", async () => {
const cam = seedCamera({ anpr: true });
db.insert(siteConfig).values({ id: 1, anprEntryEnabled: false }).run();
const vision = fakeVision({ plate: "AA111BB", confidence: 0.97 });
const bridge = new AnprBridge(db, vision, fakeSubFlow(SUB_MATCH), silentLogger());
const reads = await captureReads(() => bridge.onVehicleDetected(cam));
expect(reads).toEqual([]);
// The flag is checked FIRST — no snapshot, no analyze, no match attempt.
expect(captureSnapshot).not.toHaveBeenCalled();
expect(vision.analyze).not.toHaveBeenCalled();
});
it("still emits when the bridge is explicitly enabled (anprEntryEnabled = true)", async () => {
const cam = seedCamera({ anpr: true });
db.insert(siteConfig).values({ id: 1, anprEntryEnabled: true }).run();
const vision = fakeVision({ plate: "AA111BB", confidence: 0.97 });
const bridge = new AnprBridge(db, vision, fakeSubFlow(SUB_MATCH), silentLogger());
const reads = await captureReads(() => bridge.onVehicleDetected(cam));
expect(reads).toHaveLength(1);
});
});
+184
View File
@@ -0,0 +1,184 @@
import { randomUUID } from "node:crypto";
import { devices, deviceEvents as deviceEventsTable, eq, siteConfig, type Db, type DeviceRow } from "@parking/db";
import type { FastifyBaseLogger } from "fastify";
import { deviceEvents, type DeviceReadEvent } from "./device-events.js";
import { directionOf, type FlowDirection } from "./device-resolve.js";
import { buildCamera } from "./snapshot.js";
import type { SubscriptionFlow } from "./subscription-flow.js";
import type { VisionClient } from "./vision-client.js";
// The ANPR "bridge": a subscriber's plate, read from the lane camera, admits them through
// the SAME gated SubscriptionFlow a QR/card scan uses. It is the one missing wire between
// the camera's vehicle PUSH (hikvision-alarm.ts) and the read bus — NOT a new service.
//
// On a `vehicle`/`active` event from an OPT-IN camera (config.anpr === true), the bridge:
// pull a fresh snapshot → vision.analyze → entry confidence floor → debounce → MATCH the
// plate to a subscription → emit a DeviceReadEvent{kind:"plate"} ONLY if it matched.
// The existing onRead → ReadDispatcher then re-matches and runs the gated SubscriptionFlow
// (active / window / blocklist / car-count), which signs the entry/exit and opens the relay.
//
// INVARIANTS (see wiki/concepts/lane-presence-and-anpr-entry.md §2, append-only-event-chain.md):
// - Advisory, never sole authority: the bridge only emitRead()s — the signed decision +
// barrier open stay inside the existing flow. A spoofed printed plate is just another
// credential through the same gate.
// - Subscriber-ONLY: it MATCHES before emitting, so a random plate never reaches the
// transient plate-as-ticket exit flow.
// - Fail-soft + fire-and-forget: any snapshot/vision error degrades to the card/QR path;
// never throws into the push handler, never awaited on the camera's 200 response.
// - Opt-in per camera, and debounced (the camera re-fires ~1Hz while a car sits).
/** Camera config flag opting it into the ANPR bridge (same flag advisory ANPR uses). */
interface CameraConfig {
readonly anpr?: boolean;
readonly [k: string]: unknown;
}
/** Stricter-than-advisory confidence floor for a BARRIER-driving plate read. A near-miss
* read falls back to the subscriber's card/QR, so we'd rather skip than wrongly admit.
* Distinct from vision-client's advisory VISION_MIN_CONFIDENCE. */
function entryMinConfidence(): number {
const raw = Number(process.env.VISION_ENTRY_MIN_CONFIDENCE ?? 0.85);
return Number.isFinite(raw) && raw > 0 ? raw : 0.85;
}
/** Same plate/camera within this window = ONE credential presentation. The camera re-fires
* ~1Hz while a car is present; emitting every second would drive repeat entries (a fleet
* sub opens a 2nd occurrence) or exit spam. Required for correctness, not CPU. */
function debounceMs(): number {
const raw = Number(process.env.ANPR_DEBOUNCE_MS ?? 12_000);
return Number.isFinite(raw) && raw > 0 ? raw : 12_000;
}
export class AnprBridge {
readonly #db: Db;
readonly #vision: VisionClient | null;
readonly #subscription: SubscriptionFlow;
readonly #logger: FastifyBaseLogger;
readonly #entryMinConfidence: number;
readonly #debounceMs: number;
/** Last-fire timestamps, keyed by deviceId (camera-level, pre-snapshot) AND by
* `deviceId:plate` (post-match) — both gated against #debounceMs. */
readonly #lastFire = new Map<string, number>();
constructor(db: Db, vision: VisionClient | null, subscription: SubscriptionFlow, logger: FastifyBaseLogger) {
this.#db = db;
this.#vision = vision;
this.#subscription = subscription;
this.#logger = logger;
this.#entryMinConfidence = entryMinConfidence();
this.#debounceMs = debounceMs();
}
/**
* A camera reported a vehicle. If the camera opts into ANPR, pull a snapshot, read the
* plate, and — only if it matches a subscription — emit a plate read onto the bus.
* Fire-and-forget; fail-soft. Never throws (the push handler must always 200).
*/
async onVehicleDetected(deviceId: string): Promise<void> {
try {
if (!this.#vision?.enabled) return; // no recognizer configured
// Admin master switch (read LIVE so toggling in Site Settings takes effect with no
// restart). Gates ONLY this barrier-driving bridge — advisory snapshot-ANPR and lane
// busy/free are unaffected. Absent/unreadable config ⇒ enabled (the default).
const site = this.#db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
if (site && site.anprEntryEnabled === false) return;
const row = this.#db.select().from(devices).where(eq(devices.id, deviceId)).get();
if (!row || !row.enabled || row.category !== "camera") return;
if ((row.config as CameraConfig)?.anpr !== true) return; // opt-in only
// Camera-level debounce (pre-snapshot): a car re-firing ~1Hz must not pull a
// snapshot + analyze every second.
if (this.#debounced(deviceId)) return;
this.#stamp(deviceId);
const camera = buildCamera(row);
if (!camera) {
this.#logger.warn(`anpr-bridge: camera ${deviceId} config won't build`);
return;
}
// "both" collapses to entry purely for the capture hint (it doesn't pick the lane —
// the gated flow infers the verb from the camera's bound relay direction).
const direction: FlowDirection = directionOf(this.#db, row) === "exit" ? "exit" : "entry";
const shot = await camera.captureSnapshot({ direction });
const result = await this.#vision.analyze(shot.bytes, shot.contentType);
if (!result || !result.plate) return; // nothing read
// Entry floor — stricter than the advisory floor (analyze() still returns the plate
// object with its confidence even when its own lowConfidence flag is set).
if (result.plate.confidence < this.#entryMinConfidence) {
this.#logger.info(
`anpr-bridge: plate '${result.plate.text}' below entry floor ` +
`(${result.plate.confidence.toFixed(3)} < ${this.#entryMinConfidence}) — ignored`,
);
return;
}
const plate = result.plate.text.trim().toUpperCase();
if (!plate) return;
const e: DeviceReadEvent = {
driverId: row.driverId,
deviceId,
value: plate,
kind: "plate",
at: new Date().toISOString(),
};
// MATCH BEFORE EMIT — subscriber-only. A non-subscriber plate records advisory
// telemetry and stops; it must NEVER reach the transient plate-as-ticket exit flow.
const match = this.#subscription.match(e);
if (!match) {
this.#recordSkip(deviceId, plate, result.plate.confidence);
return;
}
// Plate-level debounce — belt-and-suspenders against a gap that slips the
// camera-level gate re-emitting the SAME plate.
const plateKey = `${deviceId}:${plate}`;
if (this.#debounced(plateKey)) return;
this.#stamp(plateKey);
this.#logger.info(
`anpr-bridge: subscriber plate '${plate}' (${result.plate.confidence.toFixed(3)}) → read bus`,
);
deviceEvents.emitRead(e); // → onRead → ReadDispatcher → gated SubscriptionFlow
} catch (err) {
// Fail-soft: an ANPR failure degrades to the subscriber's card/QR, never strands the lane.
this.#logger.warn(`anpr-bridge failed (${deviceId}): ${(err as Error).message}`);
}
}
#debounced(key: string): boolean {
const last = this.#lastFire.get(key);
return last != null && Date.now() - last < this.#debounceMs;
}
#stamp(key: string): void {
this.#lastFire.set(key, Date.now());
}
/** Advisory telemetry: a plate was read at the lane but matched no subscription. Not a
* read on the bus — just a breadcrumb so the operator can see ANPR is working. */
#recordSkip(deviceId: string, plate: string, confidence: number): void {
this.#logger.info(`anpr-bridge: plate '${plate}' matched no subscription — skipped`);
try {
this.#db
.insert(deviceEventsTable)
.values({
id: randomUUID(),
deviceId,
category: "camera",
kind: "anpr-skip",
detail: { plate, confidence, source: "anpr-bridge", reason: "no subscription match" },
occurredAt: new Date().toISOString(),
})
.run();
} catch (err) {
this.#logger.error(`anpr-bridge skip-record insert failed: ${(err as Error).message}`);
}
}
}
// DeviceRow is re-exported for the test's seed typing convenience.
export type { DeviceRow };
+20
View File
@@ -76,6 +76,16 @@ export interface DeviceStatusEvent {
readonly checkedAt: string; // ISO-8601
}
/** Lane occupancy from a camera's vehicle detection — a per-direction "busy/free"
* the booth shows as barrier lights. ADVISORY ONLY: a detection is a hint, never a
* gate (it never blocks a ticket or opens a barrier). "busy" is set by a vehicle
* `active` event; it auto-clears to "free" after a timeout (this camera class sends
* no leave/`inactive` signal — see wiki/entities/lpr-camera.md). */
export interface LaneStatusEvent {
readonly entry: boolean; // true = busy (a vehicle is at the entry vicinity)
readonly exit: boolean; // true = busy (a vehicle is at the exit vicinity)
}
class DeviceEventBus extends EventEmitter {
emitInput(event: DeviceInputEvent): void {
this.emit("input", event);
@@ -128,6 +138,16 @@ class DeviceEventBus extends EventEmitter {
this.on("ledger", cb);
return () => this.off("ledger", cb);
}
/** Emitted whenever a lane's busy/free state CHANGES (from camera vehicle
* detection). Drives the booth's barrier lights. Advisory only. */
emitLaneStatus(event: LaneStatusEvent): void {
this.emit("lane-status", event);
}
onLaneStatus(cb: (event: LaneStatusEvent) => void): () => void {
this.on("lane-status", cb);
return () => this.off("lane-status", cb);
}
}
/** Process-wide device event bus. */
+130
View File
@@ -0,0 +1,130 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { randomUUID } from "node:crypto";
import { devices, type Db } from "@parking/db";
import { createTestDb } from "@parking/db/testing";
import { LaneStatus } from "./lane-status.js";
import { deviceEvents, type LaneStatusEvent } from "./device-events.js";
import { silentLogger } from "./test-helpers.js";
// LaneStatus: a camera's vehicle detection marks its bound lane busy, then auto-clears
// after a timeout (this camera class sends no leave signal). Advisory; emits a
// lane-status change only when the busy/free state actually flips.
let db: Db;
beforeEach(() => {
({ db } = createTestDb());
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
/** Seed a controller (relay 1=entry, 2=exit, 3=both) + a camera bound to the relay
* whose direction we want, so directionOf resolves from the real bound relay. */
function seedCamera(direction: "entry" | "exit" | "both"): string {
const controllerId = randomUUID();
db.insert(devices).values({
id: controllerId,
category: "access",
driverId: "dingtian",
config: {
host: "10.0.0.5",
relays: [
{ relay: 1, direction: "entry" },
{ relay: 2, direction: "exit" },
{ relay: 3, direction: "both" },
],
},
enabled: true,
}).run();
const relay = direction === "entry" ? 1 : direction === "exit" ? 2 : 3;
const camId = randomUUID();
db.insert(devices).values({
id: camId,
category: "camera",
driverId: "hikvision",
config: { host: "10.0.0.9", controllerId, relay },
enabled: true,
}).run();
return camId;
}
/** Capture lane-status events emitted during `fn`. */
function captureEmits(fn: () => void): LaneStatusEvent[] {
const got: LaneStatusEvent[] = [];
const off = deviceEvents.onLaneStatus((e) => got.push(e));
try {
fn();
} finally {
off();
}
return got;
}
describe("LaneStatus", () => {
it("marks the camera's bound lane busy on a vehicle detection, free until then", () => {
const cam = seedCamera("entry");
const lane = new LaneStatus(db, silentLogger(), 90_000);
expect(lane.snapshot()).toEqual({ entry: false, exit: false });
const emits = captureEmits(() => lane.vehicleDetected(cam));
expect(lane.snapshot()).toEqual({ entry: true, exit: false });
expect(emits).toEqual([{ entry: true, exit: false }]); // emitted on the flip
});
it("auto-clears to free after the TTL (no leave signal from the camera)", () => {
const cam = seedCamera("entry");
const lane = new LaneStatus(db, silentLogger(), 90_000);
lane.vehicleDetected(cam);
expect(lane.snapshot().entry).toBe(true);
const emits = captureEmits(() => vi.advanceTimersByTime(90_001));
expect(lane.snapshot().entry).toBe(false);
expect(emits).toEqual([{ entry: false, exit: false }]);
});
it("re-arms the timer on each detection (a parked car keeps the lane busy)", () => {
const cam = seedCamera("entry");
const lane = new LaneStatus(db, silentLogger(), 90_000);
lane.vehicleDetected(cam);
// Re-fire just before the TTL — should NOT clear, and should push the clear out.
vi.advanceTimersByTime(80_000);
lane.vehicleDetected(cam);
vi.advanceTimersByTime(80_000); // 160s total, but only 80s since the last detect
expect(lane.snapshot().entry).toBe(true);
// Now let it lapse fully.
vi.advanceTimersByTime(90_001);
expect(lane.snapshot().entry).toBe(false);
});
it("does NOT re-emit on a repeat detection while already busy (only state flips)", () => {
const cam = seedCamera("entry");
const lane = new LaneStatus(db, silentLogger(), 90_000);
lane.vehicleDetected(cam); // flip -> emits
const emits = captureEmits(() => {
lane.vehicleDetected(cam); // already busy -> no emit
lane.vehicleDetected(cam);
});
expect(emits).toEqual([]);
});
it("a 'both'-direction camera marks BOTH lanes busy", () => {
const cam = seedCamera("both");
const lane = new LaneStatus(db, silentLogger(), 90_000);
lane.vehicleDetected(cam);
expect(lane.snapshot()).toEqual({ entry: true, exit: true });
});
it("exit camera marks only the exit lane", () => {
const cam = seedCamera("exit");
const lane = new LaneStatus(db, silentLogger(), 90_000);
lane.vehicleDetected(cam);
expect(lane.snapshot()).toEqual({ entry: false, exit: true });
});
it("ignores an unknown device id", () => {
const lane = new LaneStatus(db, silentLogger(), 90_000);
lane.vehicleDetected("nope");
expect(lane.snapshot()).toEqual({ entry: false, exit: false });
});
});
+103
View File
@@ -0,0 +1,103 @@
import { eq, devices, type Db } from "@parking/db";
import type { FastifyBaseLogger } from "fastify";
import { deviceEvents, type LaneStatusEvent } from "./device-events.js";
import { directionOf } from "./device-resolve.js";
// Lane busy/free, driven by a camera's vehicle detection. ADVISORY ONLY — a detection
// is a hint the booth shows as barrier lights; it never gates a ticket or opens a
// barrier (see wiki/entities/lpr-camera.md, the advisory-only rule).
//
// A vehicle `active` event on a camera bound to entry/exit marks THAT lane busy and
// (re)arms an auto-clear timer. This camera class sends NO leave/`inactive` signal, so
// "free" is timeout-driven: the camera re-fires `active` while a car sits in the zone
// (each refreshing the timer); once the car leaves, the actives stop and the lane
// flips free after BUSY_TTL_MS. A "both"-direction camera marks BOTH lanes.
/** How long after the last vehicle detection a lane stays "busy" before clearing.
* Must exceed the camera's `active` re-fire interval so a still-present car keeps the
* lane busy. MEASURED on the test unit (controlled in/out test): the re-fire rate is
* MOVEMENT-driven, not a fixed rate — ~1-3s apart while the car moves, but stretching
* to ~15-25s when it sits MOTIONLESS in the zone. So the TTL must clear the still-car
* gap (~25s) or a parked car flickers free. The camera has ~no dwell lag (it goes
* silent within a second of the car leaving), so 30s clears promptly after departure
* while keeping a motionless car solidly busy. Override with LANE_BUSY_TTL_MS. */
export function busyTtlMs(): number {
const raw = Number(process.env.LANE_BUSY_TTL_MS ?? 30_000);
return Number.isFinite(raw) && raw > 0 ? raw : 30_000;
}
export class LaneStatus {
readonly #db: Db;
readonly #logger: FastifyBaseLogger;
readonly #ttlMs: number;
#entry = false;
#exit = false;
#entryTimer: ReturnType<typeof setTimeout> | null = null;
#exitTimer: ReturnType<typeof setTimeout> | null = null;
constructor(db: Db, logger: FastifyBaseLogger, ttlMs = busyTtlMs()) {
this.#db = db;
this.#logger = logger;
this.#ttlMs = ttlMs;
}
/** Current snapshot (for the WS hello). */
snapshot(): LaneStatusEvent {
return { entry: this.#entry, exit: this.#exit };
}
/**
* A vehicle was detected by camera `deviceId`. Resolves the camera's bound direction
* and marks that lane busy + (re)arms its auto-clear. Best-effort: an unknown camera
* or a non-vehicle caller is the caller's concern — this only handles a confirmed
* vehicle detection. Emits a lane-status change only when the state actually flips.
*/
vehicleDetected(deviceId: string): void {
const row = this.#db.select().from(devices).where(eq(devices.id, deviceId)).get();
if (!row) return;
const dir = directionOf(this.#db, row);
if (dir === "entry" || dir === "both") this.#mark("entry");
if (dir === "exit" || dir === "both") this.#mark("exit");
}
#mark(lane: "entry" | "exit"): void {
const was = lane === "entry" ? this.#entry : this.#exit;
if (lane === "entry") this.#entry = true;
else this.#exit = true;
// (Re)arm the auto-clear — each detection pushes the free-flip further out.
const existing = lane === "entry" ? this.#entryTimer : this.#exitTimer;
if (existing) clearTimeout(existing);
const timer = setTimeout(() => this.#clear(lane), this.#ttlMs);
timer.unref?.(); // never hold the process open
if (lane === "entry") this.#entryTimer = timer;
else this.#exitTimer = timer;
if (!was) {
this.#logger.info(`lane-status: ${lane} -> busy`);
this.#emit();
}
}
#clear(lane: "entry" | "exit"): void {
if (lane === "entry") {
this.#entry = false;
this.#entryTimer = null;
} else {
this.#exit = false;
this.#exitTimer = null;
}
this.#logger.info(`lane-status: ${lane} -> free`);
this.#emit();
}
#emit(): void {
deviceEvents.emitLaneStatus(this.snapshot());
}
/** Clear timers on shutdown. */
stop(): void {
if (this.#entryTimer) clearTimeout(this.#entryTimer);
if (this.#exitTimer) clearTimeout(this.#exitTimer);
}
}
+189
View File
@@ -0,0 +1,189 @@
import { beforeEach, describe, expect, it } from "vitest";
import { randomUUID } from "node:crypto";
import {
eq,
isNull,
roles,
rolePermissions,
subscriptionCredentials,
subscriptionPlans,
subscriptions,
tariffs,
users,
type Db,
} from "@parking/db";
import { createTestDb } from "@parking/db/testing";
import {
listRecycleBin,
purge,
restore,
restoreBlockedReason,
softDelete,
sweepExpired,
} from "./recycle-bin.js";
// Soft delete / recycle bin. Pins: a delete STAMPS (keeps the row), the bin lists
// soft-deleted items across kinds, restore brings them back, purge does the real
// DELETE (+ children), a restore that would collide with a live row is blocked, and the
// retention sweep purges only items past the window.
let db: Db;
beforeEach(() => {
({ db } = createTestDb());
});
function seedUser(username: string): string {
const id = randomUUID();
db.insert(roles).values({ id: "admin", name: "admin", builtin: 1 }).onConflictDoNothing().run();
db.insert(users).values({ id, username, passwordHash: "x", roleId: "admin" }).run();
return id;
}
function seedRole(name: string): string {
const id = randomUUID();
db.insert(roles).values({ id, name, builtin: 0 }).run();
db.insert(rolePermissions).values({ roleId: id, permission: "site:read" }).run();
return id;
}
function seedSubscription(holder: string): string {
const id = randomUUID();
db.insert(subscriptions).values({ id, holderName: holder, period: "month" }).run();
db.insert(subscriptionCredentials).values({ id: randomUUID(), subscriptionId: id, kind: "qr", value: `qr-${id}` }).run();
return id;
}
function seedPlan(planId: string, versions = 2): void {
for (let i = 0; i < versions; i++) {
db.insert(subscriptionPlans).values({
id: randomUUID(),
planId,
name: planId,
period: "month",
pricePerPeriodMinor: 100000,
currency: "ALL",
effectiveFrom: `2026-0${i + 1}-01T00:00:00.000Z`,
}).run();
}
}
describe("softDelete + restore + purge", () => {
it("stamps the row instead of removing it, and hides it from a live query", () => {
const id = seedUser("alice");
expect(softDelete(db, "user", id, "admin-1")).toBe(true);
const row = db.select().from(users).where(eq(users.id, id)).get();
expect(row).toBeDefined(); // still there
expect(row?.deletedAt).toBeTruthy();
expect(row?.deletedBy).toBe("admin-1");
// A live-only query no longer sees it.
expect(db.select().from(users).where(isNull(users.deletedAt)).all()).toHaveLength(0);
});
it("soft-deleting an already-deleted row is a no-op (returns false)", () => {
const id = seedUser("bob");
expect(softDelete(db, "user", id, "a")).toBe(true);
expect(softDelete(db, "user", id, "a")).toBe(false);
});
it("restore clears the stamps and brings the row back to the live set", () => {
const id = seedRole("valet");
softDelete(db, "role", id, "a");
expect(restore(db, "role", id)).toBe(true);
const row = db.select().from(roles).where(eq(roles.id, id)).get();
expect(row?.deletedAt).toBeNull();
expect(db.select().from(roles).where(isNull(roles.deletedAt)).all().map((r) => r.id)).toContain(id);
});
it("purge removes a soft-deleted row + its children; refuses a LIVE row", () => {
const id = seedSubscription("carlos");
// Cannot purge while live (purge only touches soft-deleted rows).
expect(purge(db, "subscription", id)).toBe(false);
expect(db.select().from(subscriptions).where(eq(subscriptions.id, id)).get()).toBeDefined();
softDelete(db, "subscription", id, "a");
expect(purge(db, "subscription", id)).toBe(true);
expect(db.select().from(subscriptions).where(eq(subscriptions.id, id)).get()).toBeUndefined();
// Children gone too.
expect(db.select().from(subscriptionCredentials).where(eq(subscriptionCredentials.subscriptionId, id)).all()).toHaveLength(0);
});
});
describe("versioned plans", () => {
it("soft-deletes / restores / purges ALL versions of a planId together", () => {
seedPlan("hotel-daily", 3);
expect(softDelete(db, "plan", "hotel-daily", "a")).toBe(true);
expect(db.select().from(subscriptionPlans).where(isNull(subscriptionPlans.deletedAt)).all()).toHaveLength(0);
// The bin lists the plan as ONE item, not three.
const planItems = listRecycleBin(db).filter((i) => i.kind === "plan");
expect(planItems).toHaveLength(1);
expect(planItems[0]?.id).toBe("hotel-daily");
expect(restore(db, "plan", "hotel-daily")).toBe(true);
expect(db.select().from(subscriptionPlans).where(isNull(subscriptionPlans.deletedAt)).all()).toHaveLength(3);
softDelete(db, "plan", "hotel-daily", "a");
expect(purge(db, "plan", "hotel-daily")).toBe(true);
expect(db.select().from(subscriptionPlans).all()).toHaveLength(0);
});
});
describe("listRecycleBin", () => {
it("collects soft-deleted items across every kind, newest-deleted first", () => {
const u = seedUser("dora");
const r = seedRole("guard");
const t = randomUUID();
db.insert(tariffs).values({ id: t, scope: "site", name: "Site" }).run();
softDelete(db, "user", u, "a");
softDelete(db, "role", r, "a");
softDelete(db, "tariff", t, "a");
const items = listRecycleBin(db);
expect(items.map((i) => i.kind).sort()).toEqual(["role", "tariff", "user"]);
// Each carries a human label + the deletedAt stamp.
expect(items.find((i) => i.kind === "user")?.label).toBe("dora");
expect(items.every((i) => i.deletedAt)).toBe(true);
});
});
describe("restoreBlockedReason", () => {
// NB: the DB `username`/`name` UNIQUE spans live AND soft-deleted rows, so a live
// duplicate can't even be INSERTed while the deleted one exists (the create route
// returns a clear 409 instead — see routes/users.ts). restoreBlockedReason is a
// belt-and-suspenders guard at restore time; verify it returns null in the normal
// case (nothing colliding) so a clean restore is never wrongly blocked.
it("does not block a normal restore (no live collision)", () => {
const u = seedUser("eve");
softDelete(db, "user", u, "a");
expect(restoreBlockedReason(db, "user", u)).toBeNull();
const r = seedRole("cleaner");
softDelete(db, "role", r, "a");
expect(restoreBlockedReason(db, "role", r)).toBeNull();
});
});
describe("sweepExpired (retention)", () => {
it("purges items deleted longer than the window ago, keeps recent ones", () => {
const old = seedUser("old");
const fresh = seedUser("fresh");
softDelete(db, "user", old, "a");
softDelete(db, "user", fresh, "a");
// Backdate `old`'s deletion to 40 days ago.
const longAgo = new Date(Date.now() - 40 * 86_400_000).toISOString();
db.update(users).set({ deletedAt: longAgo }).where(eq(users.id, old)).run();
const purged = sweepExpired(db, 30);
expect(purged.user).toBe(1);
expect(db.select().from(users).where(eq(users.id, old)).get()).toBeUndefined();
expect(db.select().from(users).where(eq(users.id, fresh)).get()).toBeDefined();
});
it("days <= 0 disables the sweep (keep forever)", () => {
const id = seedUser("keeper");
softDelete(db, "user", id, "a");
db.update(users).set({ deletedAt: new Date(Date.now() - 999 * 86_400_000).toISOString() }).where(eq(users.id, id)).run();
const purged = sweepExpired(db, 0);
expect(purged.user).toBe(0);
expect(db.select().from(users).where(eq(users.id, id)).get()).toBeDefined();
});
});
+206
View File
@@ -0,0 +1,206 @@
import {
and,
eq,
isNotNull,
isNull,
lte,
rolePermissions,
roles,
subscriptionCredentials,
subscriptionPlans,
subscriptionPlates,
subscriptions,
tariffs,
users,
type Db,
} from "@parking/db";
// Soft delete + recycle bin. Accidental hard-deletes of master data (a user, role,
// subscription, plan, tariff) used to be unrecoverable. Now a DELETE STAMPS the row
// (`deleted_at` = now, `deleted_by` = admin) instead of removing it; it disappears from
// every catalog (the list queries filter `deleted_at IS NULL`) but survives in the
// recycle bin, where an admin can RESTORE it (clear the stamps) or PURGE it (the real
// DELETE). A retention sweep auto-purges items deleted longer than the window ago.
//
// Scope: only the MUTABLE master-data tables below. The signed, append-only ledger is
// NOT here — it has no delete path by design. See wiki/concepts/soft-delete.md.
/** The soft-deletable resource kinds, as they appear in the recycle-bin API. */
export type ResourceKind = "user" | "role" | "subscription" | "plan" | "tariff";
export const RESOURCE_KINDS: ResourceKind[] = ["user", "role", "subscription", "plan", "tariff"];
/** Default retention window before a soft-deleted item is auto-purged (days). Override
* with RECYCLE_BIN_RETENTION_DAYS. 0/negative disables the sweep (keep forever). */
export function retentionDays(): number {
const raw = Number(process.env.RECYCLE_BIN_RETENTION_DAYS ?? 30);
return Number.isFinite(raw) ? raw : 30;
}
/** A row surfaced in the recycle bin (normalised across resource kinds). */
export interface RecycleBinItem {
readonly kind: ResourceKind;
/** The id used to restore/purge. For a versioned PLAN this is the stable planId. */
readonly id: string;
/** Human label for the list (username, role/plan/tariff name, subscriber holder). */
readonly label: string;
readonly deletedAt: string;
readonly deletedBy: string | null;
}
const NOW = () => new Date().toISOString();
// --- Per-resource helpers ----------------------------------------------------
// Subscriptions/users/roles/tariffs are 1 row per id. PLANS are versioned (N rows per
// plan_id) — stamp/clear/delete ALL versions of the plan_id together.
/** Soft-delete a row by id. Returns false if no live row matched (404). PLAN uses planId. */
export function softDelete(db: Db, kind: ResourceKind, id: string, byUserId: string): boolean {
const stamp = { deletedAt: NOW(), deletedBy: byUserId };
switch (kind) {
case "user":
return db.update(users).set(stamp).where(and(eq(users.id, id), isNull(users.deletedAt))).run().changes > 0;
case "role":
return db.update(roles).set(stamp).where(and(eq(roles.id, id), isNull(roles.deletedAt))).run().changes > 0;
case "subscription":
return db.update(subscriptions).set(stamp).where(and(eq(subscriptions.id, id), isNull(subscriptions.deletedAt))).run().changes > 0;
case "plan":
return db.update(subscriptionPlans).set(stamp).where(and(eq(subscriptionPlans.planId, id), isNull(subscriptionPlans.deletedAt))).run().changes > 0;
case "tariff":
return db.update(tariffs).set(stamp).where(and(eq(tariffs.id, id), isNull(tariffs.deletedAt))).run().changes > 0;
}
}
/** Restore a soft-deleted row (clear the stamps). Returns false if nothing was restored. */
export function restore(db: Db, kind: ResourceKind, id: string): boolean {
const clear = { deletedAt: null, deletedBy: null };
switch (kind) {
case "user":
return db.update(users).set(clear).where(and(eq(users.id, id), isNotNull(users.deletedAt))).run().changes > 0;
case "role":
return db.update(roles).set(clear).where(and(eq(roles.id, id), isNotNull(roles.deletedAt))).run().changes > 0;
case "subscription":
return db.update(subscriptions).set(clear).where(and(eq(subscriptions.id, id), isNotNull(subscriptions.deletedAt))).run().changes > 0;
case "plan":
return db.update(subscriptionPlans).set(clear).where(and(eq(subscriptionPlans.planId, id), isNotNull(subscriptionPlans.deletedAt))).run().changes > 0;
case "tariff":
return db.update(tariffs).set(clear).where(and(eq(tariffs.id, id), isNotNull(tariffs.deletedAt))).run().changes > 0;
}
}
/** True if restoring would collide with a LIVE row (e.g. a user with the same username
* was re-created after the delete). The caller turns this into a 409 so the admin
* understands why restore is blocked. */
export function restoreBlockedReason(db: Db, kind: ResourceKind, id: string): string | null {
if (kind === "user") {
const row = db.select().from(users).where(eq(users.id, id)).get();
if (row && db.select().from(users).where(and(eq(users.username, row.username), isNull(users.deletedAt))).get()) {
return `a live user named "${row.username}" already exists`;
}
} else if (kind === "role") {
const row = db.select().from(roles).where(eq(roles.id, id)).get();
if (row && db.select().from(roles).where(and(eq(roles.name, row.name), isNull(roles.deletedAt))).get()) {
return `a live role named "${row.name}" already exists`;
}
}
return null;
}
// --- Restore ordering note --------------------------------------------------
// A restored USER points at a roleId; if that role is itself deleted, the user reappears
// with a dangling role. We don't auto-cascade (keep it predictable); the bin lists both
// and the admin restores the role too. The role guard already resolves a missing role to
// an empty permission set (safe-by-default), so a dangling role never escalates.
/** Hard-delete (purge) a soft-deleted row + its children. The real DELETE. Returns false
* if no soft-deleted row matched (so you can't purge a live row through this path). */
export function purge(db: Db, kind: ResourceKind, id: string): boolean {
switch (kind) {
case "user":
return db.delete(users).where(and(eq(users.id, id), isNotNull(users.deletedAt))).run().changes > 0;
case "role": {
// Children (role_permissions) only matter once the role row is gone; purge both.
const ok = db.delete(roles).where(and(eq(roles.id, id), isNotNull(roles.deletedAt))).run().changes > 0;
if (ok) deleteRolePermissions(db, id);
return ok;
}
case "subscription": {
const ok = db.delete(subscriptions).where(and(eq(subscriptions.id, id), isNotNull(subscriptions.deletedAt))).run().changes > 0;
if (ok) deleteSubscriptionChildren(db, id);
return ok;
}
case "plan":
return db.delete(subscriptionPlans).where(and(eq(subscriptionPlans.planId, id), isNotNull(subscriptionPlans.deletedAt))).run().changes > 0;
case "tariff":
return db.delete(tariffs).where(and(eq(tariffs.id, id), isNotNull(tariffs.deletedAt))).run().changes > 0;
}
}
// Child cleanup on purge (role_permissions / subscription credentials + plates).
function deleteRolePermissions(db: Db, roleId: string): void {
db.delete(rolePermissions).where(eq(rolePermissions.roleId, roleId)).run();
}
function deleteSubscriptionChildren(db: Db, id: string): void {
db.delete(subscriptionCredentials).where(eq(subscriptionCredentials.subscriptionId, id)).run();
db.delete(subscriptionPlates).where(eq(subscriptionPlates.subscriptionId, id)).run();
}
// --- Listing the bin --------------------------------------------------------
/** All soft-deleted items across every resource kind, newest-deleted first. */
export function listRecycleBin(db: Db): RecycleBinItem[] {
const items: RecycleBinItem[] = [];
for (const r of db.select().from(users).where(isNotNull(users.deletedAt)).all()) {
items.push({ kind: "user", id: r.id, label: r.fullName || r.username, deletedAt: r.deletedAt!, deletedBy: r.deletedBy });
}
for (const r of db.select().from(roles).where(isNotNull(roles.deletedAt)).all()) {
items.push({ kind: "role", id: r.id, label: r.name, deletedAt: r.deletedAt!, deletedBy: r.deletedBy });
}
for (const r of db.select().from(subscriptions).where(isNotNull(subscriptions.deletedAt)).all()) {
items.push({ kind: "subscription", id: r.id, label: r.holderName || r.id, deletedAt: r.deletedAt!, deletedBy: r.deletedBy });
}
// Plans are versioned: collapse to one item per plan_id (the latest version's name).
const planSeen = new Set<string>();
const planRows = db.select().from(subscriptionPlans).where(isNotNull(subscriptionPlans.deletedAt)).all();
planRows.sort((a, b) => b.effectiveFrom.localeCompare(a.effectiveFrom));
for (const r of planRows) {
if (planSeen.has(r.planId)) continue;
planSeen.add(r.planId);
items.push({ kind: "plan", id: r.planId, label: r.name, deletedAt: r.deletedAt!, deletedBy: r.deletedBy });
}
for (const r of db.select().from(tariffs).where(isNotNull(tariffs.deletedAt)).all()) {
items.push({ kind: "tariff", id: r.id, label: r.name, deletedAt: r.deletedAt!, deletedBy: r.deletedBy });
}
return items.sort((a, b) => b.deletedAt.localeCompare(a.deletedAt));
}
// --- Retention sweep --------------------------------------------------------
/** Purge every soft-deleted row deleted more than `retentionDays()` ago. Returns the
* count purged per kind. Safe to call repeatedly (idempotent). */
export function sweepExpired(db: Db, days = retentionDays()): Record<ResourceKind, number> {
const out: Record<ResourceKind, number> = { user: 0, role: 0, subscription: 0, plan: 0, tariff: 0 };
if (!Number.isFinite(days) || days <= 0) return out; // keep-forever
const cutoff = new Date(Date.now() - days * 86_400_000).toISOString();
// Collect ids first so children purge through the same path as a manual purge.
for (const r of db.select().from(users).where(and(isNotNull(users.deletedAt), lte(users.deletedAt, cutoff))).all()) {
if (purge(db, "user", r.id)) out.user++;
}
for (const r of db.select().from(roles).where(and(isNotNull(roles.deletedAt), lte(roles.deletedAt, cutoff))).all()) {
if (purge(db, "role", r.id)) out.role++;
}
for (const r of db.select().from(subscriptions).where(and(isNotNull(subscriptions.deletedAt), lte(subscriptions.deletedAt, cutoff))).all()) {
if (purge(db, "subscription", r.id)) out.subscription++;
}
const planIds = new Set(
db.select().from(subscriptionPlans).where(and(isNotNull(subscriptionPlans.deletedAt), lte(subscriptionPlans.deletedAt, cutoff))).all().map((r) => r.planId),
);
for (const planId of planIds) if (purge(db, "plan", planId)) out.plan++;
for (const r of db.select().from(tariffs).where(and(isNotNull(tariffs.deletedAt), lte(tariffs.deletedAt, cutoff))).all()) {
if (purge(db, "tariff", r.id)) out.tariff++;
}
return out;
}
+3 -1
View File
@@ -69,7 +69,9 @@ export async function authRoutes(app: FastifyInstance, db: Db): Promise<void> {
// Always run a bcrypt compare to avoid leaking which usernames exist (timing).
const hash = user?.passwordHash ?? "$2b$10$invalidinvalidinvalidinvalidinvalidinvalidinv";
const ok = await bcrypt.compare(password, hash);
if (!user || !ok) {
// A soft-deleted user (in the recycle bin) cannot log in — treat as invalid, with no
// distinct error so a deleted account isn't enumerable.
if (!user || !ok || user.deletedAt) {
return reply.code(401).send({ error: "invalid credentials" });
}
@@ -0,0 +1,289 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import Fastify, { type FastifyInstance as RawFastify } from "fastify";
import { createTestDb } from "@parking/db/testing";
import { and, eq, inArray, devices, deviceEvents as deviceEventsTable, type Db } from "@parking/db";
import type { FastifyInstance } from "fastify";
import { buildServer } from "../server.js";
import { hikvisionAlarmRoutes } from "./hikvision-alarm.js";
import type { AnprBridge } from "../anpr-entry.js";
import { seedUser, login } from "../test-helpers.js";
// Hikvision Alarm Server push ingress. Verifies the discovery endpoint: a vehicle-
// detection POST from the camera's configured IP is accepted, summarized (eventType /
// target / plate pulled out of the XML), and recorded verbatim as a kind:"alarm"
// device_event — while a wrong source IP or a push-disabled device is refused.
const CAM_IP = "10.0.10.121";
const CAM_ID = "cam-1";
let db: Db;
let close: () => void;
let app: FastifyInstance;
beforeEach(async () => {
const t = createTestDb();
db = t.db;
close = t.close;
app = await buildServer({ db });
await app.ready();
});
afterEach(async () => {
await app.close();
close();
});
function seedHikCamera(cfg: Record<string, unknown> = {}) {
db.insert(devices).values({
id: CAM_ID,
category: "camera",
driverId: "hikvision",
config: { host: CAM_IP, alarmPushEnabled: true, ...cfg },
enabled: true,
}).run();
}
/** A representative Hikvision smart-event POST body (vehicle target). The real firmware
* payload may differ; the endpoint stores it verbatim regardless — this asserts the
* best-effort summary extraction over a plausible shape. */
const VEHICLE_XML = `<?xml version="1.0" encoding="UTF-8"?>
<EventNotificationAlert version="2.0" xmlns="http://www.hikvision.com/ver20/XMLSchema">
<ipAddress>10.0.10.121</ipAddress>
<channelID>1</channelID>
<dateTime>2026-06-22T10:15:30+02:00</dateTime>
<eventType>fielddetection</eventType>
<eventState>active</eventState>
<DetectionRegionList>
<DetectionRegionEntry><detectionTarget>vehicle</detectionTarget></DetectionRegionEntry>
</DetectionRegionList>
</EventNotificationAlert>`;
function alarmEvents(): { detail: Record<string, unknown> }[] {
return db
.select()
.from(deviceEventsTable)
.where(and(eq(deviceEventsTable.deviceId, CAM_ID), eq(deviceEventsTable.kind, "alarm")))
.all() as { detail: Record<string, unknown> }[];
}
/** Every recorded push for a device — accepted (kind:"alarm") AND rejected
* (kind:"alarm-rejected"). */
function allRecorded(deviceId: string): { kind: string; detail: Record<string, unknown> }[] {
return db
.select()
.from(deviceEventsTable)
.where(and(eq(deviceEventsTable.deviceId, deviceId), inArray(deviceEventsTable.kind, ["alarm", "alarm-rejected"])))
.all() as { kind: string; detail: Record<string, unknown> }[];
}
describe("Hikvision Alarm Server push", () => {
it("accepts a vehicle event from the camera IP and records it with a parsed summary", async () => {
seedHikCamera();
const res = await app.inject({
method: "POST",
url: `/api/devices/hikvision/${CAM_ID}/event`,
headers: { "content-type": "application/xml" },
payload: VEHICLE_XML,
remoteAddress: CAM_IP,
});
expect(res.statusCode).toBe(200);
const events = alarmEvents();
expect(events).toHaveLength(1);
const d = events[0]!.detail;
expect(d.source).toBe("hikvision-alarm-server");
expect(d.eventType).toBe("fielddetection");
expect(d.target).toBe("vehicle");
expect(d.ip).toBe(CAM_IP);
// The raw body is kept verbatim for inspection.
expect(String(d.rawHead)).toContain("EventNotificationAlert");
});
it("accepts the legacy string \"true\" for alarmPushEnabled (setup form quirk)", async () => {
// The setup checkbox historically saved a STRING "true" instead of a boolean; the
// guard must coerce it, not silently reject a feature the admin enabled.
seedHikCamera({ alarmPushEnabled: "true" });
const res = await app.inject({
method: "POST",
url: `/api/devices/hikvision/${CAM_ID}/event`,
headers: { "content-type": "application/xml" },
payload: VEHICLE_XML,
remoteAddress: CAM_IP,
});
expect(res.statusCode).toBe(200);
expect(alarmEvents()).toHaveLength(1);
});
it("pulls a plate out of an ANPR-style payload when present", async () => {
seedHikCamera();
const anpr = `<EventNotificationAlert><eventType>ANPR</eventType>
<ANPR><plateNumber>AA123BB</plateNumber></ANPR></EventNotificationAlert>`;
const res = await app.inject({
method: "POST",
url: `/api/devices/hikvision/${CAM_ID}/event`,
headers: { "content-type": "application/xml" },
payload: anpr,
remoteAddress: CAM_IP,
});
expect(res.statusCode).toBe(200);
expect(alarmEvents()[0]!.detail.plate).toBe("AA123BB");
});
it("accepts an unknown/JSON content-type as raw bytes (discovery-first)", async () => {
seedHikCamera();
const res = await app.inject({
method: "POST",
url: `/api/devices/hikvision/${CAM_ID}/event`,
headers: { "content-type": "application/octet-stream" },
payload: Buffer.from('{"eventType":"vehicleDetection"}'),
remoteAddress: CAM_IP,
});
expect(res.statusCode).toBe(200);
expect(alarmEvents()[0]!.detail.eventType).toBe("vehicleDetection");
});
it("accepts a push from ANY source IP when skipSourceIpCheck is set (WSL rewrites it)", async () => {
// WSL mirrored mode rewrites the inbound source to the host's own IP, so the camera's
// real IP never survives and a strict check rejects every push. With the opt-out, a
// push from the 'wrong' IP is accepted.
seedHikCamera({ skipSourceIpCheck: true });
const res = await app.inject({
method: "POST",
url: `/api/devices/hikvision/${CAM_ID}/event`,
headers: { "content-type": "application/xml" },
payload: VEHICLE_XML,
remoteAddress: "10.0.10.203", // the rewritten host IP, NOT the camera's
});
expect(res.statusCode).toBe(200);
expect(alarmEvents()).toHaveLength(1);
expect(alarmEvents()[0]!.detail.target).toBe("vehicle");
});
it("rejects a push from a DIFFERENT source IP (404, nothing recorded)", async () => {
seedHikCamera();
const res = await app.inject({
method: "POST",
url: `/api/devices/hikvision/${CAM_ID}/event`,
headers: { "content-type": "application/xml" },
payload: VEHICLE_XML,
remoteAddress: "10.0.10.200", // not the camera
});
expect(res.statusCode).toBe(404);
// No ACCEPTED alarm...
expect(alarmEvents()).toHaveLength(0);
// ...but the rejection IS recorded (with the reason), so "nothing arrived" is never
// ambiguous — you can see it came in and why it was refused.
const recorded = allRecorded(CAM_ID);
expect(recorded).toHaveLength(1);
expect(recorded[0]!.kind).toBe("alarm-rejected");
expect(String(recorded[0]!.detail.reason)).toMatch(/source IP/i);
});
it("rejects when alarm push is disabled on the device", async () => {
seedHikCamera({ alarmPushEnabled: false });
const res = await app.inject({
method: "POST",
url: `/api/devices/hikvision/${CAM_ID}/event`,
headers: { "content-type": "application/xml" },
payload: VEHICLE_XML,
remoteAddress: CAM_IP,
});
expect(res.statusCode).toBe(404);
});
it("rejects an unknown device id", async () => {
const res = await app.inject({
method: "POST",
url: `/api/devices/hikvision/nope/event`,
headers: { "content-type": "application/xml" },
payload: VEHICLE_XML,
remoteAddress: CAM_IP,
});
expect(res.statusCode).toBe(404);
expect(res.json().reason).toMatch(/unknown device/i);
});
it("GET /api/devices/hikvision/alarms lists accepted AND rejected pushes, newest first", async () => {
seedHikCamera();
// One accepted (right IP) + one rejected (wrong IP).
await app.inject({ method: "POST", url: `/api/devices/hikvision/${CAM_ID}/event`, headers: { "content-type": "application/xml" }, payload: VEHICLE_XML, remoteAddress: CAM_IP });
await app.inject({ method: "POST", url: `/api/devices/hikvision/${CAM_ID}/event`, headers: { "content-type": "application/xml" }, payload: VEHICLE_XML, remoteAddress: "10.0.10.200" });
const { username, password } = await seedUser(db, { username: "admin1", roleId: "admin" });
const { cookie } = await login(app, username, password);
const res = await app.inject({ method: "GET", url: "/api/devices/hikvision/alarms", headers: { cookie } });
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body.count).toBe(2);
// Both accepted and rejected appear, with the accepted/reason flags.
expect(body.alarms.some((a: { accepted: boolean }) => a.accepted === true)).toBe(true);
const rejected = body.alarms.find((a: { accepted: boolean }) => a.accepted === false);
expect(rejected.reason).toMatch(/source IP/i);
});
it("the alarms read endpoint is gated (device:read) — 401 without a session", async () => {
const res = await app.inject({ method: "GET", url: "/api/devices/hikvision/alarms" });
expect(res.statusCode).toBe(401);
});
});
// The ANPR bridge is handed each vehicle detection (fire-and-forget). We register the
// routes on a bare instance with a SPY bridge to assert exactly when it's invoked —
// only on a vehicle target that isn't `inactive`. (The bridge's own logic is covered in
// anpr-entry.test.ts.)
describe("Hikvision Alarm Server → ANPR bridge wiring", () => {
let rawApp: RawFastify;
let rawDb: Db;
let rawClose: () => void;
let onVehicleDetected: ReturnType<typeof vi.fn>;
beforeEach(async () => {
const t = createTestDb();
rawDb = t.db;
rawClose = t.close;
onVehicleDetected = vi.fn(async () => {});
const bridge = { onVehicleDetected } as unknown as AnprBridge;
rawApp = Fastify();
await hikvisionAlarmRoutes(rawApp, rawDb, undefined, bridge);
await rawApp.ready();
rawDb.insert(devices).values({
id: CAM_ID,
category: "camera",
driverId: "hikvision",
config: { host: CAM_IP, alarmPushEnabled: true },
enabled: true,
}).run();
});
afterEach(async () => {
await rawApp.close();
rawClose();
});
async function post(payload: string) {
return rawApp.inject({
method: "POST",
url: `/api/devices/hikvision/${CAM_ID}/event`,
headers: { "content-type": "application/xml" },
payload,
remoteAddress: CAM_IP,
});
}
it("hands a vehicle (active) detection to the bridge", async () => {
const res = await post(VEHICLE_XML);
expect(res.statusCode).toBe(200);
expect(onVehicleDetected).toHaveBeenCalledTimes(1);
expect(onVehicleDetected).toHaveBeenCalledWith(CAM_ID);
});
it("does NOT call the bridge for a human target", async () => {
const human = VEHICLE_XML.replace("vehicle", "human");
await post(human);
expect(onVehicleDetected).not.toHaveBeenCalled();
});
it("does NOT call the bridge on an `inactive` (leave) vehicle event", async () => {
const leave = VEHICLE_XML.replace("<eventState>active</eventState>", "<eventState>inactive</eventState>");
await post(leave);
expect(onVehicleDetected).not.toHaveBeenCalled();
});
});
+280
View File
@@ -0,0 +1,280 @@
import { randomUUID } from "node:crypto";
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import { desc, eq, inArray, devices, deviceEvents as deviceEventsTable, type Db } from "@parking/db";
import { deviceEvents } from "../device-events.js";
import { requirePermission } from "../auth.js";
import { verifyDigest } from "../digest-auth.js";
import type { LaneStatus } from "../lane-status.js";
import type { AnprBridge } from "../anpr-entry.js";
// Hikvision "Alarm Server" event PUSH ingress. The newer-firmware cameras (Event →
// Smart/VCA with "Detection Target: Human/Vehicle", Notify Surveillance Center, Alarm
// Settings → Alarm Server) HTTP-POST an EventNotificationAlert to a URL we host every
// time the chosen target is detected. This is the same machine-call pattern as the
// Dingtian Input Link push (routes/devices.ts): source-IP guarded, NOT behind the SPA
// cookie/CSRF.
//
// DISCOVERY-FIRST. Hik's push format varies by model/firmware (event XML, or multipart
// with an attached JPEG, or — on some ANPR units — an <ANPR>/<plateNumber> block). So
// this endpoint is deliberately PERMISSIVE: it accepts ANY content-type as raw bytes,
// records the verbatim body as a `kind:"alarm"` device_event, and best-effort extracts a
// summary (eventType / target / plate). The goal of this first cut is to SEE exactly what
// a given camera sends — inspect via GET /api/events or the logs — before we wire it into
// the read bus / a snapshot trigger. It never opens a barrier (a plate read is advisory,
// never the sole reason; see wiki/concepts/append-only-event-chain.md).
//
// See wiki/entities/lpr-camera.md, wiki/concepts/device-input-flow.md.
interface HikDeviceConfig {
host?: string;
alarmPushEnabled?: boolean | string | number;
pushUser?: string;
pushPassword?: string;
/** Skip the source-IP guard for this device's pushes. The source IP is the primary
* LAN guard, but it's UNRELIABLE in some environments — notably WSL mirrored mode,
* which rewrites an inbound packet's source to the host's OWN address, so the camera's
* real IP never survives and a strict check rejects every push. When pushUser/
* pushPassword (Digest) are set, that auth is the real guard and source-IP adds little;
* this flag lets a deployment opt out. The signed ledger remains the anti-fraud truth. */
skipSourceIpCheck?: boolean | string | number;
}
/** Coerce a device-config flag to a boolean. The config is loosely-typed JSON from the
* setup form, which has historically stored a checkbox as the STRING "true" (a form-
* serialization quirk) — so accept true / "true" / 1 / "1" / "yes" / "on", reject the
* rest. Being lenient here means a stray "true" never silently disables a real feature. */
function isOn(v: unknown): boolean {
if (v === true) return true;
if (typeof v === "number") return v === 1;
if (typeof v === "string") return /^(1|true|yes|on)$/i.test(v.trim());
return false;
}
/** A best-effort summary pulled out of the raw push body (XML or JSON), for the device
* event detail + the log line. Absent fields just mean "not found in this firmware's
* payload" — the raw body is always stored so nothing is lost. */
interface AlarmSummary {
eventType?: string;
/** `active` (target entered the region) | `inactive` (target left). The edge that
* drives lane busy/free — see [[lpr-camera]] / hikvision-alarm.ts. */
eventState?: string;
target?: string;
plate?: string;
dateTime?: string;
channelId?: string;
}
function clientIp(req: FastifyRequest): string {
return req.ip.replace(/^::ffff:/, "");
}
/** First capture group of `re` in `s`, trimmed, or undefined. */
function pick(s: string, re: RegExp): string | undefined {
const m = re.exec(s);
return m?.[1]?.trim() || undefined;
}
/**
* Best-effort summary extraction. Hikvision event XML uses tags like <eventType>,
* <dateTime>, <channelID>; smart/ANPR events add target/plate tags whose exact names
* vary by firmware (<detectionTarget>, <targetType>, <plateNumber>, <licensePlate>).
* We probe several spellings; whatever doesn't match is simply absent. JSON bodies are
* scanned for the same keys.
*/
function summarize(body: string): AlarmSummary {
return {
eventType: pick(body, /<eventType>([^<]+)<\/eventType>/i) ?? pick(body, /"eventType"\s*:\s*"([^"]+)"/i),
eventState: pick(body, /<eventState>([^<]+)<\/eventState>/i) ?? pick(body, /"eventState"\s*:\s*"([^"]+)"/i),
target:
pick(body, /<(?:detectionTarget|targetType|objectType)>([^<]+)<\//i) ??
pick(body, /"(?:detectionTarget|targetType|objectType)"\s*:\s*"([^"]+)"/i),
plate:
pick(body, /<(?:plateNumber|licensePlate|plateNo)>([^<]+)<\//i) ??
pick(body, /"(?:plateNumber|licensePlate|plateNo)"\s*:\s*"([^"]+)"/i),
dateTime: pick(body, /<dateTime>([^<]+)<\/dateTime>/i),
channelId: pick(body, /<channelID>([^<]+)<\/channelID>/i) ?? pick(body, /<channelId>([^<]+)<\/channelId>/i),
};
}
export async function hikvisionAlarmRoutes(
app: FastifyInstance,
db: Db,
laneStatus?: LaneStatus,
anprBridge?: AnprBridge,
): Promise<void> {
// Accept ANY content-type as a raw Buffer (the camera may POST application/xml,
// multipart/form-data with a JPEG, or text). Fastify's default JSON parser would 415
// or empty these — we want the bytes verbatim. Scoped to THIS app instance via a
// wildcard parser; a 10 MB cap covers an event + an attached frame.
app.addContentTypeParser("*", { parseAs: "buffer", bodyLimit: 10 * 1024 * 1024 }, (_req, body, done) => {
done(null, body);
});
/** Record EVERY push (accepted or rejected) as a device_event so the read endpoint /
* DB always shows that SOMETHING arrived — the key fix: a rejected push used to log a
* warning and vanish, so "no event" was ambiguous (never sent? or sent + rejected?). */
function record(args: {
deviceId: string;
method: string;
accepted: boolean;
reason?: string;
ip: string;
contentType: string;
raw: Buffer;
summary: AlarmSummary;
}): void {
try {
db.insert(deviceEventsTable)
.values({
id: randomUUID(),
deviceId: args.deviceId,
category: "camera",
kind: args.accepted ? "alarm" : "alarm-rejected",
detail: {
source: "hikvision-alarm-server",
accepted: args.accepted,
method: args.method,
...(args.reason ? { reason: args.reason } : {}),
ip: args.ip,
contentType: args.contentType,
bytes: args.raw.length,
...args.summary,
// Readable head verbatim (the XML part); truncated to keep the row small.
rawHead: args.raw.toString("utf8").slice(0, 8000),
},
occurredAt: new Date().toISOString(),
})
.run();
} catch (err) {
app.log.error(`hik-alarm device-event insert failed: ${(err as Error).message}`);
}
}
const handle = async (req: FastifyRequest<{ Params: { deviceId: string } }>, reply: FastifyReply) => {
const { deviceId } = req.params;
const method = req.method;
const row = await db.select().from(devices).where(eq(devices.id, deviceId)).get();
const cfg = row?.config as HikDeviceConfig | undefined;
const ip = clientIp(req);
const contentType = String(req.headers["content-type"] ?? "");
const raw: Buffer = Buffer.isBuffer(req.body) ? (req.body as Buffer) : Buffer.from("");
const summary = summarize(raw.toString("utf8"));
// Log EVERY hit immediately (method + ip + size), before any guard — so even a probe
// that gets rejected is visible in the dev log the instant it arrives.
app.log.info(`[hik-alarm:${deviceId}] HIT ${method} from ${ip} (${contentType || "no-ct"} ${raw.length}B)`);
// Guard: must be a known hikvision device with alarm-push enabled, posting from its
// configured host IP. Source-IP is the primary guard on the LAN (like the Dingtian).
// On rejection we STILL record it (with the precise reason) so a push that reached us
// never silently disappears — that's what makes "is it coming?" answerable.
// The source-IP check is skipped when the device opts out (skipSourceIpCheck) — needed
// where the network rewrites the inbound source IP (e.g. WSL mirrored mode rewrites it
// to the host's own address), so a strict match can never pass. Digest auth (when set)
// and the signed ledger remain the real guards. See HikDeviceConfig.skipSourceIpCheck.
const skipIp = isOn(cfg?.skipSourceIpCheck);
let reason: string | null = null;
if (!row || !cfg) reason = "unknown device id";
else if (row.driverId !== "hikvision") reason = `device is ${row.driverId}, not hikvision`;
else if (!isOn(cfg.alarmPushEnabled)) reason = "alarm push not enabled on this device (tick it in Setup)";
else if (!cfg.host) reason = "device has no host IP configured";
else if (!skipIp && ip !== cfg.host) reason = `source IP ${ip} != device host ${cfg.host} (set skipSourceIpCheck if the network rewrites it, e.g. WSL)`;
if (reason) {
app.log.warn(`[hik-alarm:${deviceId}] REJECTED ${method} from ${ip} (${contentType} ${raw.length}B): ${reason}`);
record({ deviceId, method, accepted: false, reason, ip, contentType, raw, summary });
return reply.code(404).send({ error: "not found", reason });
}
// Optional Digest auth — only when the admin configured push creds (some firmware
// can't authenticate the Alarm Server call; then we rely on source-IP alone).
if (cfg!.pushUser && cfg!.pushPassword) {
if (!verifyDigest(req, reply, { user: cfg!.pushUser, password: cfg!.pushPassword })) {
record({ deviceId, method, accepted: false, reason: "digest auth failed/challenge", ip, contentType, raw, summary });
return; // 401 challenge already sent
}
}
// Loud log so the operator can SEE the payload during testing.
app.log.info(
`[hik-alarm:${deviceId}] ACCEPTED ${method} ${ip} ${contentType} ${raw.length}B ` +
`event=${summary.eventType ?? "?"}/${summary.eventState ?? "?"} target=${summary.target ?? "?"} plate=${summary.plate ?? "-"}`,
);
record({ deviceId, method, accepted: true, ip, contentType, raw, summary });
// Lane busy/free: a VEHICLE detection marks the camera's bound lane busy (advisory,
// for the booth barrier lights). Only on a vehicle target that's `active` — an
// `inactive` (leave) isn't sent by this camera class, so the lane auto-clears on a
// timeout in LaneStatus. We filter to vehicle per the booth's "vehicle only" intent.
const isVehicleActive =
(summary.target ?? "").toLowerCase() === "vehicle" &&
(summary.eventState ?? "active").toLowerCase() !== "inactive";
if (laneStatus && isVehicleActive) {
laneStatus.vehicleDetected(deviceId);
}
// ANPR BRIDGE: on a vehicle detection, if this camera opts into ANPR (config.anpr),
// pull a snapshot → read the plate → if it matches a SUBSCRIBER, emit a plate read
// onto the bus, which the existing gated SubscriptionFlow turns into an entry/exit +
// barrier open. Fire-and-forget — NEVER awaited on the 200 path (the camera must get
// a prompt ack or it retry-storms), and fail-soft inside the bridge. See anpr-entry.ts.
if (anprBridge && isVehicleActive) {
void anprBridge.onVehicleDetected(deviceId);
}
// Surface on the in-process bus as a generic breadcrumb so a live listener can show
// "camera saw a vehicle". NOT a DeviceReadEvent yet — that (plate identity driving
// entry/exit) is the deliberate next step once we know the real payload.
deviceEvents.emitInput({ driverId: "hikvision", deviceId, input: 0, edge: "on", at: new Date().toISOString(), source: "push" });
// 200 so the camera considers the alarm delivered and doesn't retry-storm.
return reply.code(200).send({ ok: true });
};
// Listen for EVERY method on the event path. The camera (and its "Test" button) may
// probe with GET/HEAD/OPTIONS/PUT, not just POST — and a method we don't register gets
// Fastify's generic 404, which the camera reads as "service available" while our
// handler never runs (so nothing is recorded). Registering all methods means ANYTHING
// that hits this URL reaches `handle` and is captured (the method is logged + stored),
// so we can finally SEE exactly what the camera sends. See wiki/entities/lpr-camera.md.
// (HEAD is auto-added by Fastify alongside GET — don't register it explicitly.)
for (const method of ["POST", "GET", "PUT", "PATCH", "DELETE", "OPTIONS"] as const) {
app.route({ method, url: "/api/devices/hikvision/:deviceId/event", handler: handle });
}
// Read endpoint: the recent alarm pushes (accepted AND rejected), newest first — so you
// can SEE in the browser whether events are arriving and why any were refused, instead
// of grepping the dev log or querying SQLite. Gated device:read (admin device view).
app.get<{ Querystring: { limit?: string } }>(
"/api/devices/hikvision/alarms",
{ preHandler: requirePermission("device:read") },
async (req) => {
const limit = Math.min(Math.max(Number(req.query.limit) || 50, 1), 500);
const rows = db
.select()
.from(deviceEventsTable)
.where(inArray(deviceEventsTable.kind, ["alarm", "alarm-rejected"]))
.orderBy(desc(deviceEventsTable.occurredAt))
.limit(limit)
.all();
const alarms = rows.map((r) => {
const d = (r.detail ?? {}) as Record<string, unknown>;
return {
at: r.occurredAt,
deviceId: r.deviceId,
accepted: d.accepted === true,
method: (d.method as string) ?? null,
reason: (d.reason as string) ?? null,
ip: (d.ip as string) ?? null,
contentType: (d.contentType as string) ?? null,
bytes: (d.bytes as number) ?? 0,
eventType: (d.eventType as string) ?? null,
eventState: (d.eventState as string) ?? null,
target: (d.target as string) ?? null,
plate: (d.plate as string) ?? null,
rawHead: (d.rawHead as string) ?? null,
};
});
return { count: alarms.length, alarms };
},
);
}
@@ -0,0 +1,114 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { createTestDb } from "@parking/db/testing";
import { type Db } from "@parking/db";
import type { FastifyInstance } from "fastify";
import { buildServer } from "../server.js";
import { seedUser, login } from "../test-helpers.js";
// HTTP integration for soft delete + recycle bin: an admin DELETE soft-deletes (the user
// leaves the list, can't log in), the bin lists it, restore brings it back, and a deleted
// user can log in again. Drives the REAL app over a fresh in-memory DB via app.inject.
let db: Db;
let close: () => void;
let app: FastifyInstance;
beforeEach(async () => {
const t = createTestDb();
db = t.db;
close = t.close;
app = await buildServer({ db });
await app.ready();
});
afterEach(async () => {
await app.close();
close();
});
/** Log in an admin and return the auth headers for mutations. */
async function asAdmin() {
const { username, password } = await seedUser(db, { username: "boss", roleId: "admin" });
const { cookie, csrf } = await login(app, username, password);
return { cookie, csrf };
}
describe("soft delete via the resource DELETE route", () => {
it("DELETE /api/users/:id soft-deletes: user leaves the list and can't log in, but is restorable", async () => {
const { cookie, csrf } = await asAdmin();
// Create a victim user to delete.
await seedUser(db, { username: "victim", password: "victim-pass-123", roleId: "admin" });
const victim = (await app.inject({ method: "GET", url: "/api/users", headers: { cookie } })).json()
.users.find((u: { username: string; id: string }) => u.username === "victim");
expect(victim).toBeDefined();
// Delete (soft).
const del = await app.inject({
method: "DELETE", url: `/api/users/${victim.id}`,
headers: { cookie, "x-csrf-token": csrf },
});
expect(del.statusCode).toBeLessThan(300);
// Gone from the live list.
const list = (await app.inject({ method: "GET", url: "/api/users", headers: { cookie } })).json();
expect(list.users.some((u: { username: string }) => u.username === "victim")).toBe(false);
// Can't log in.
const relogin = await app.inject({ method: "POST", url: "/api/auth/login", payload: { username: "victim", password: "victim-pass-123" } });
expect(relogin.statusCode).toBe(401);
// Shows in the recycle bin.
const bin = (await app.inject({ method: "GET", url: "/api/recycle-bin", headers: { cookie } })).json();
expect(bin.items.some((i: { kind: string; label: string }) => i.kind === "user" && i.label === "victim")).toBe(true);
// Restore → reappears + can log in.
const restore = await app.inject({
method: "POST", url: `/api/recycle-bin/user/${victim.id}/restore`,
headers: { cookie, "x-csrf-token": csrf },
});
expect(restore.statusCode).toBeLessThan(300);
const relogin2 = await app.inject({ method: "POST", url: "/api/auth/login", payload: { username: "victim", password: "victim-pass-123" } });
expect(relogin2.statusCode).toBe(200);
});
it("purge permanently removes a soft-deleted user", async () => {
const { cookie, csrf } = await asAdmin();
await seedUser(db, { username: "gone", password: "gone-pass-1234", roleId: "admin" });
const id = (await app.inject({ method: "GET", url: "/api/users", headers: { cookie } })).json()
.users.find((u: { username: string }) => u.username === "gone").id;
await app.inject({ method: "DELETE", url: `/api/users/${id}`, headers: { cookie, "x-csrf-token": csrf } });
const purge = await app.inject({
method: "DELETE", url: `/api/recycle-bin/user/${id}`,
headers: { cookie, "x-csrf-token": csrf },
});
expect(purge.statusCode).toBe(204);
const bin = (await app.inject({ method: "GET", url: "/api/recycle-bin", headers: { cookie } })).json();
expect(bin.items.some((i: { label: string }) => i.label === "gone")).toBe(false);
});
it("the recycle bin is gated — a user without recyclebin:read is 403", async () => {
const { username, password } = await seedUser(db, {
username: "plain", roleId: "plain", permissions: ["user:read"],
});
const { cookie } = await login(app, username, password);
const res = await app.inject({ method: "GET", url: "/api/recycle-bin", headers: { cookie } });
expect(res.statusCode).toBe(403);
});
it("recreating a user with a soft-deleted user's username gives a clear 409", async () => {
const { cookie, csrf } = await asAdmin();
await seedUser(db, { username: "dup", password: "dup-pass-12345", roleId: "admin" });
const id = (await app.inject({ method: "GET", url: "/api/users", headers: { cookie } })).json()
.users.find((u: { username: string }) => u.username === "dup").id;
await app.inject({ method: "DELETE", url: `/api/users/${id}`, headers: { cookie, "x-csrf-token": csrf } });
const create = await app.inject({
method: "POST", url: "/api/users",
headers: { cookie, "x-csrf-token": csrf },
payload: { username: "dup", password: "new-pass-12345", roleId: "admin" },
});
expect(create.statusCode).toBe(409);
expect(create.json().error).toMatch(/recycle bin/i);
});
});
+67
View File
@@ -0,0 +1,67 @@
import type { FastifyInstance } from "fastify";
import type { Db } from "@parking/db";
import { requirePermission, bumpPermsCache } from "../auth.js";
import {
listRecycleBin,
purge,
restore,
restoreBlockedReason,
retentionDays,
RESOURCE_KINDS,
type ResourceKind,
} from "../recycle-bin.js";
// Recycle bin API — view / restore / purge soft-deleted master data. The actual
// soft-delete STAMP happens in each resource's own DELETE route (users/roles/
// subscriptions/plans/tariffs); this is the way back. Admin-grade (recyclebin:*).
// See recycle-bin.ts, wiki/concepts/soft-delete.md.
function isKind(s: string): s is ResourceKind {
return (RESOURCE_KINDS as string[]).includes(s);
}
export async function recycleBinRoutes(app: FastifyInstance, db: Db): Promise<void> {
// List everything in the bin (+ the retention window so the UI can warn how long
// items survive before auto-purge).
app.get(
"/api/recycle-bin",
{ preHandler: requirePermission("recyclebin:read") },
async () => ({ items: listRecycleBin(db), retentionDays: retentionDays() }),
);
// Restore a soft-deleted item (clear the stamps → it reappears in its catalog).
// Blocked with a 409 when a live row would collide (e.g. the username was reused).
app.post<{ Params: { kind: string; id: string } }>(
"/api/recycle-bin/:kind/:id/restore",
{ preHandler: requirePermission("recyclebin:update") },
async (req, reply) => {
const { kind, id } = req.params;
if (!isKind(kind)) return reply.code(400).send({ error: `unknown resource kind: ${kind}` });
const blocked = restoreBlockedReason(db, kind, id);
if (blocked) return reply.code(409).send({ error: `cannot restore: ${blocked}` });
const ok = restore(db, kind, id);
if (!ok) return reply.code(404).send({ error: "no deleted item to restore" });
// A restored role/user changes the authz picture — drop the permission cache.
if (kind === "role" || kind === "user") bumpPermsCache();
app.log.info(`recycle-bin: restored ${kind} ${id}`);
return { kind, id, restored: true };
},
);
// Purge (permanently delete) a soft-deleted item + its children. Irreversible.
app.delete<{ Params: { kind: string; id: string } }>(
"/api/recycle-bin/:kind/:id",
{ preHandler: requirePermission("recyclebin:delete") },
async (req, reply) => {
const { kind, id } = req.params;
if (!isKind(kind)) return reply.code(400).send({ error: `unknown resource kind: ${kind}` });
const ok = purge(db, kind, id);
if (!ok) return reply.code(404).send({ error: "no deleted item to purge" });
if (kind === "role" || kind === "user") bumpPermsCache();
app.log.warn(`recycle-bin: PURGED ${kind} ${id} (permanent)`);
return reply.code(204).send();
},
);
}
+13 -9
View File
@@ -1,8 +1,9 @@
import { randomUUID } from "node:crypto";
import type { FastifyInstance } from "fastify";
import { eq, rolePermissions, roles, users, type Db } from "@parking/db";
import { and, eq, isNull, rolePermissions, roles, users, type Db } from "@parking/db";
import { ADMIN_ROLE_ID, PERMISSIONS, type Permission } from "@parking/shared";
import { bumpPermsCache, permissionsFor, requirePermission } from "../auth.js";
import { softDelete } from "../recycle-bin.js";
// Role management (admin). Roles are DATA: an admin composes a role from the
// code-defined PERMISSIONS grid (resource:action), and users are assigned one
@@ -56,7 +57,7 @@ export async function roleRoutes(app: FastifyInstance, db: Db): Promise<void> {
.where(eq(rolePermissions.roleId, roleId))
.all()
.map((r) => r.permission);
const userCount = db.select().from(users).where(eq(users.roleId, roleId)).all().length;
const userCount = db.select().from(users).where(and(eq(users.roleId, roleId), isNull(users.deletedAt))).all().length;
// The admin role always reports the full grid (it's enforced in code).
return {
id: role.id,
@@ -75,9 +76,10 @@ export async function roleRoutes(app: FastifyInstance, db: Db): Promise<void> {
}
}
// The full permission grid (for the role-composer checkbox UI) + every role.
// The full permission grid (for the role-composer checkbox UI) + every LIVE role.
// Soft-deleted roles live in the recycle bin, not here.
app.get("/api/roles", { preHandler: readGuard }, async () => {
const all = db.select().from(roles).all();
const all = db.select().from(roles).where(isNull(roles.deletedAt)).all();
return {
catalog: PERMISSIONS,
roles: all.map((r) => roleView(r.id)).filter((r) => r != null),
@@ -143,23 +145,25 @@ export async function roleRoutes(app: FastifyInstance, db: Db): Promise<void> {
},
);
// Delete a role. Refused if it's built-in or any user still holds it.
// Delete a role — SOFT (recycle bin). Refused if built-in or any LIVE user still holds
// it. The row is stamped deleted (recoverable), not removed; its permission rows are
// KEPT so a restore brings the role back intact. Restore/purge from the recycle bin.
app.delete<{ Params: { id: string } }>(
"/api/roles/:id",
{ preHandler: deleteGuard },
async (req, reply) => {
const id = req.params.id;
const role = db.select().from(roles).where(eq(roles.id, id)).get();
const role = db.select().from(roles).where(and(eq(roles.id, id), isNull(roles.deletedAt))).get();
if (!role) return reply.code(404).send({ error: "role not found" });
if (role.builtin === 1) {
return reply.code(409).send({ error: "the built-in admin role cannot be deleted" });
}
const holders = db.select().from(users).where(eq(users.roleId, id)).all().length;
// Only LIVE holders block deletion (a soft-deleted user's role assignment is moot).
const holders = db.select().from(users).where(and(eq(users.roleId, id), isNull(users.deletedAt))).all().length;
if (holders > 0) {
return reply.code(409).send({ error: `cannot delete a role still assigned to ${holders} user(s)` });
}
db.delete(rolePermissions).where(eq(rolePermissions.roleId, id)).run();
db.delete(roles).where(eq(roles.id, id)).run();
softDelete(db, "role", id, req.user.sub);
bumpPermsCache();
return { ok: true };
},
+11
View File
@@ -32,6 +32,9 @@ interface SiteConfigBody extends Partial<Record<TextField, string | null>> {
/** Reserve a spot in occupancy for each active subscriber's car(s), even when not
* parked — so transients see "full" sooner and the subscriber's spot is held. */
reserveSubscriberSpots?: boolean;
/** Master switch for the ANPR subscriber-entry bridge (auto-open on a subscriber's
* plate read). OFF → subscribers fall back to card/QR; advisory ANPR still records. */
anprEntryEnabled?: boolean;
}
/** Shape returned by GET/PUT: capacity + the booth flag + the subscription default
@@ -41,6 +44,7 @@ type SiteConfig = {
exitVoucherDefault: boolean;
subscriptionMonthlyPriceMinor: number | null;
reserveSubscriberSpots: boolean;
anprEntryEnabled: boolean;
} & Record<TextField, string | null>;
function toSiteConfig(row: typeof siteConfig.$inferSelect | undefined): SiteConfig {
@@ -49,6 +53,7 @@ function toSiteConfig(row: typeof siteConfig.$inferSelect | undefined): SiteConf
exitVoucherDefault: row?.exitVoucherDefault ?? false,
subscriptionMonthlyPriceMinor: row?.subscriptionMonthlyPriceMinor ?? null,
reserveSubscriberSpots: row?.reserveSubscriberSpots ?? false,
anprEntryEnabled: row?.anprEntryEnabled ?? true,
} as SiteConfig;
for (const f of TEXT_FIELDS) out[f] = row?.[f] ?? null;
return out;
@@ -106,6 +111,12 @@ export async function siteRoutes(app: FastifyInstance, db: Db): Promise<void> {
}
patch.reserveSubscriberSpots = body.reserveSubscriberSpots;
}
if ("anprEntryEnabled" in body) {
if (typeof body.anprEntryEnabled !== "boolean") {
return reply.code(400).send({ error: "anprEntryEnabled must be a boolean" });
}
patch.anprEntryEnabled = body.anprEntryEnabled;
}
for (const f of TEXT_FIELDS) {
if (f in body) patch[f] = normText(body[f]);
}
+21 -5
View File
@@ -1,8 +1,9 @@
import { randomUUID } from "node:crypto";
import type { FastifyInstance } from "fastify";
import { desc, eq, subscriptionPlans, subscriptions, type Db } from "@parking/db";
import { and, desc, eq, isNull, subscriptionPlans, subscriptions, type Db } from "@parking/db";
import { SUBSCRIPTION_PERIODS, type PlanTimeframes, type SubscriptionPeriod } from "@parking/shared";
import { requirePermission } from "../auth.js";
import { softDelete } from "../recycle-bin.js";
import { siteTz } from "../subscription-window.js";
// Subscription PLAN catalog — admin-composed, versioned config the operator SELLS
@@ -75,7 +76,14 @@ export async function subscriptionPlanRoutes(app: FastifyInstance, db: Db): Prom
// per planId (latest active version with effectiveFrom ≤ now). Operators selling
// need the current list; the admin catalog screen asks for ?all=1.
app.get<{ Querystring: { all?: string } }>("/api/subscription-plans", { preHandler: readGuard }, async (req) => {
const rows = db.select().from(subscriptionPlans).orderBy(desc(subscriptionPlans.effectiveFrom)).all();
// Exclude soft-deleted plan versions — those live in the recycle bin. (A plan is
// versioned; a soft-delete stamps every version row of the planId.)
const rows = db
.select()
.from(subscriptionPlans)
.where(isNull(subscriptionPlans.deletedAt))
.orderBy(desc(subscriptionPlans.effectiveFrom))
.all();
if (req.query?.all) return { plans: rows };
const now = new Date().toISOString();
// Newest-effective active version wins per planId.
@@ -154,12 +162,19 @@ export async function subscriptionPlanRoutes(app: FastifyInstance, db: Db): Prom
// DELETE a plan entirely — allowed ONLY when NO subscription references it (any
// version). A referenced plan version MUST survive: a subscription's planVersionId is
// needed to reprice/audit that sale, so deleting it would dangle. 409 with the count
// when in use (the admin should retire instead). Removes all versions of the planId.
// when in use (the admin should retire instead). SOFT delete (recycle bin): stamps all
// versions of the planId; a restore brings the plan back; purge does the real removal.
app.delete<{ Params: { planId: string } }>(
"/api/subscription-plans/:planId",
{ preHandler: planGuard },
async (req, reply) => {
const refs = db.select().from(subscriptions).where(eq(subscriptions.planId, req.params.planId)).all();
// Only LIVE subscriptions block deletion (a soft-deleted subscriber's planId ref is
// itself in the bin; if it's restored later, the plan can be restored too).
const refs = db
.select()
.from(subscriptions)
.where(and(eq(subscriptions.planId, req.params.planId), isNull(subscriptions.deletedAt)))
.all();
if (refs.length > 0) {
return reply.code(409).send({
error: "plan is in use and cannot be deleted",
@@ -167,7 +182,8 @@ export async function subscriptionPlanRoutes(app: FastifyInstance, db: Db): Prom
subscribers: refs.length,
});
}
db.delete(subscriptionPlans).where(eq(subscriptionPlans.planId, req.params.planId)).run();
const ok = softDelete(db, "plan", req.params.planId, req.user.sub);
if (!ok) return reply.code(404).send({ error: "plan not found" });
return { planId: req.params.planId, deleted: true };
},
);
+12 -9
View File
@@ -1,9 +1,10 @@
import { randomBytes, randomUUID } from "node:crypto";
import type { FastifyInstance } from "fastify";
import { eq, devices, subscriptionCredentials, subscriptionPlans, subscriptionPlates, subscriptions, type Db } from "@parking/db";
import { and, eq, isNull, devices, subscriptionCredentials, subscriptionPlans, subscriptionPlates, subscriptions, type Db } from "@parking/db";
import { NoPrinterAvailableError } from "@parking/devices";
import type { SubscriptionPlan, SubscriptionQuote, Tender } from "@parking/shared";
import { requirePermission, roleHasPermissions } from "../auth.js";
import { softDelete } from "../recycle-bin.js";
import { invalidateHolder } from "../event-enrich.js";
import { printSubscriptionCard } from "../booth-print.js";
import type { CredentialCapture } from "../credential-capture.js";
@@ -226,9 +227,10 @@ export async function subscriptionRoutes(
return { plan, validFrom, validTo, quantity, quote };
}
// List all subscriptions (with their credentials + plates).
// List all LIVE subscriptions (with their credentials + plates). Soft-deleted ones
// live in the recycle bin, not here.
app.get("/api/subscriptions", { preHandler: readGuard }, async () => {
const rows = db.select().from(subscriptions).all();
const rows = db.select().from(subscriptions).where(isNull(subscriptions.deletedAt)).all();
return { subscriptions: rows.map((r) => loadAggregate(r.id)) };
});
@@ -532,16 +534,17 @@ export async function subscriptionRoutes(
},
);
// Hard delete a subscription + its child rows. (Past ledger events that reference it
// are untouched — the audit trail is append-only and independent of this row.)
// Delete a subscription — SOFT (recycle bin). The row + its credential/plate children
// are KEPT (stamped deleted) so a restore brings the subscriber back intact; it leaves
// the catalog and stops opening the barrier (the entry flow filters deleted). Past
// ledger events that reference it are untouched (append-only). Restore/purge from the
// recycle bin. (Distinct from /revoke, which BARS but keeps the subscriber visible.)
app.delete<{ Params: { id: string } }>(
"/api/subscriptions/:id",
{ preHandler: deleteGuard },
async (req, reply) => {
const r = db.delete(subscriptions).where(eq(subscriptions.id, req.params.id)).run();
if (r.changes === 0) return reply.code(404).send({ error: "subscription not found" });
db.delete(subscriptionCredentials).where(eq(subscriptionCredentials.subscriptionId, req.params.id)).run();
db.delete(subscriptionPlates).where(eq(subscriptionPlates.subscriptionId, req.params.id)).run();
const ok = softDelete(db, "subscription", req.params.id, req.user.sub);
if (!ok) return reply.code(404).send({ error: "subscription not found" });
invalidateHolder(req.params.id);
return reply.code(204).send();
},
+6 -3
View File
@@ -1,6 +1,6 @@
import { randomUUID } from "node:crypto";
import type { FastifyInstance } from "fastify";
import { desc, eq, ledgerEvents, siteConfig, tariffVersions, tariffs, type Db } from "@parking/db";
import { and, desc, eq, isNull, ledgerEvents, siteConfig, tariffVersions, tariffs, type Db } from "@parking/db";
import {
computeFee,
isTariffV2,
@@ -48,9 +48,12 @@ export async function tariffRoutes(app: FastifyInstance, db: Db): Promise<void>
// Publishing a new version changes what customers are charged.
const writeGuard = requirePermission("tariff:update");
// The single site tariff row, created on first read/publish.
// The single site tariff row, created on first read/publish. A soft-deleted (recycle-
// bin) tariff is ignored here so a fresh one is created — the deleted one waits in the
// bin for restore/purge. (Tariffs have soft-delete support for completeness; today the
// site runs one tariff and there's no delete button — recovery is via the recycle bin.)
function ensureSiteTariff(): string {
const existing = db.select().from(tariffs).where(eq(tariffs.scope, "site")).get();
const existing = db.select().from(tariffs).where(and(eq(tariffs.scope, "site"), isNull(tariffs.deletedAt))).get();
if (existing) return existing.id;
const id = randomUUID();
db.insert(tariffs).values({ id, scope: "site", name: SITE_TARIFF_NAME }).run();
+22 -10
View File
@@ -1,9 +1,10 @@
import { randomUUID } from "node:crypto";
import bcrypt from "bcrypt";
import type { FastifyInstance } from "fastify";
import { eq, roles, users, type Db } from "@parking/db";
import { and, eq, isNull, roles, users, type Db } from "@parking/db";
import { ADMIN_ROLE_ID } from "@parking/shared";
import { permissionsFor, requirePermission } from "../auth.js";
import { softDelete } from "../recycle-bin.js";
// User management (admin). Users are created/edited at runtime here — the
// install-time seed-admin.mjs only bootstraps the FIRST admin. Each user has one
@@ -64,9 +65,10 @@ export async function userRoutes(app: FastifyInstance, db: Db): Promise<void> {
const updateGuard = requirePermission("user:update");
const deleteGuard = requirePermission("user:delete");
/** Count users currently holding the protected admin role. */
/** Count LIVE users currently holding the protected admin role. A soft-deleted admin
* doesn't count — they can't log in — so the no-lockout check uses live admins only. */
function adminCount(): number {
return db.select().from(users).where(eq(users.roleId, ADMIN_ROLE_ID)).all().length;
return db.select().from(users).where(and(eq(users.roleId, ADMIN_ROLE_ID), isNull(users.deletedAt))).all().length;
}
/** True if removing/relocating `userId` from admin would leave zero admins. */
@@ -112,9 +114,10 @@ export async function userRoutes(app: FastifyInstance, db: Db): Promise<void> {
return false;
}
// List all users (no password hashes) + their role names for display.
// List all LIVE users (no password hashes) + their role names for display. Soft-deleted
// users live in the recycle bin, not here.
app.get("/api/users", { preHandler: readGuard }, async () => {
const rows = db.select().from(users).all();
const rows = db.select().from(users).where(isNull(users.deletedAt)).all();
const roleRows = db.select().from(roles).all();
const roleName = new Map(roleRows.map((r) => [r.id, r.name]));
return {
@@ -140,8 +143,15 @@ export async function userRoutes(app: FastifyInstance, db: Db): Promise<void> {
if (exceedsCaller(req.user.roleId, roleId)) {
return reply.code(403).send({ error: "cannot assign a role with permissions beyond your own" });
}
if (db.select().from(users).where(eq(users.username, username)).get()) {
return reply.code(409).send({ error: "username already exists" });
const clash = db.select().from(users).where(eq(users.username, username)).get();
if (clash) {
// The username is UNIQUE across live AND soft-deleted rows. If a DELETED user holds
// it, point the admin at the recycle bin (restore or purge) rather than a bare 409.
return reply.code(409).send({
error: clash.deletedAt
? "username belongs to a deleted user — restore or purge it from the recycle bin first"
: "username already exists",
});
}
const id = randomUUID();
const passwordHash = await bcrypt.hash(password, 12);
@@ -221,13 +231,15 @@ export async function userRoutes(app: FastifyInstance, db: Db): Promise<void> {
},
);
// Delete a user. Refused if it's the last admin (no-lockout).
// Delete a user — SOFT (recycle bin). Refused if it's the last admin (no-lockout).
// The row is stamped deleted (recoverable), not removed; it vanishes from the list and
// can't log in. Restore/purge from the recycle bin. See recycle-bin.ts.
app.delete<{ Params: { id: string } }>(
"/api/users/:id",
{ preHandler: deleteGuard },
async (req, reply) => {
const id = req.params.id;
const target = db.select().from(users).where(eq(users.id, id)).get();
const target = db.select().from(users).where(and(eq(users.id, id), isNull(users.deletedAt))).get();
if (!target) {
return reply.code(404).send({ error: "user not found" });
}
@@ -238,7 +250,7 @@ export async function userRoutes(app: FastifyInstance, db: Db): Promise<void> {
if (isLastAdmin(id)) {
return reply.code(409).send({ error: "cannot delete the last admin" });
}
db.delete(users).where(eq(users.id, id)).run();
softDelete(db, "user", id, req.user.sub);
return { ok: true };
},
);
+17 -5
View File
@@ -2,9 +2,10 @@ import type { FastifyInstance } from "fastify";
import type { Db } from "@parking/db";
import type { LedgerEvent } from "@parking/shared";
import { roleHasPermissions } from "../auth.js";
import { deviceEvents } from "../device-events.js";
import { deviceEvents, type LaneStatusEvent } from "../device-events.js";
import { enrichEvent } from "../event-enrich.js";
import type { DeviceMonitor } from "../device-monitor.js";
import type { LaneStatus } from "../lane-status.js";
import { getOccupancy } from "../occupancy.js";
// Live booth feed over a WebSocket. The booth UI opens ONE socket and receives
@@ -52,12 +53,18 @@ function isAllowedOrigin(origin: string | undefined, host: string | undefined):
}
type OutMsg =
| { kind: "hello"; occupancy: ReturnType<typeof getOccupancy>; devices: unknown }
| { kind: "hello"; occupancy: ReturnType<typeof getOccupancy>; devices: unknown; lanes: LaneStatusEvent }
| { kind: "ledger"; event: unknown; occupancy: ReturnType<typeof getOccupancy> }
| { kind: "printer-status"; event: unknown }
| { kind: "device-status"; event: unknown };
| { kind: "device-status"; event: unknown }
| { kind: "lane-status"; lanes: LaneStatusEvent };
export async function wsRoutes(app: FastifyInstance, db: Db, deviceMonitor: DeviceMonitor): Promise<void> {
export async function wsRoutes(
app: FastifyInstance,
db: Db,
deviceMonitor: DeviceMonitor,
laneStatus: LaneStatus,
): Promise<void> {
app.get(
"/api/ws",
{
@@ -89,7 +96,7 @@ export async function wsRoutes(app: FastifyInstance, db: Db, deviceMonitor: Devi
// Initial snapshot so the client renders immediately, before any event:
// occupancy AND the current device-status set (for the footer).
send({ kind: "hello", occupancy: getOccupancy(db), devices: deviceMonitor.snapshot() });
send({ kind: "hello", occupancy: getOccupancy(db), devices: deviceMonitor.snapshot(), lanes: laneStatus.snapshot() });
// Subscribe to the live buses. Each handler recomputes occupancy from the
// ledger (cheap fold) so the pushed count is always authoritative.
@@ -106,11 +113,16 @@ export async function wsRoutes(app: FastifyInstance, db: Db, deviceMonitor: Devi
const offDevice = deviceEvents.onDeviceStatus((event) => {
send({ kind: "device-status", event });
});
// Lane busy/free (camera vehicle detection → booth barrier lights). Advisory.
const offLane = deviceEvents.onLaneStatus((lanes) => {
send({ kind: "lane-status", lanes });
});
socket.on("close", () => {
offLedger();
offPrinter();
offDevice();
offLane();
});
},
);
+44 -1
View File
@@ -24,8 +24,13 @@ import { authRoutes } from "./routes/auth.js";
import { userRoutes } from "./routes/users.js";
import { roleRoutes } from "./routes/roles.js";
import { deviceRoutes } from "./routes/devices.js";
import { hikvisionAlarmRoutes } from "./routes/hikvision-alarm.js";
import { LaneStatus } from "./lane-status.js";
import { AnprBridge } from "./anpr-entry.js";
import { eventRoutes } from "./routes/events.js";
import { reportRoutes } from "./routes/reports.js";
import { recycleBinRoutes } from "./routes/recycle-bin.js";
import { sweepExpired, retentionDays } from "./recycle-bin.js";
import { payRoutes } from "./routes/pay.js";
import { subscriptionRoutes } from "./routes/subscriptions.js";
import { subscriptionPlanRoutes } from "./routes/subscription-plans.js";
@@ -112,6 +117,15 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
// the device's lane_devices config (written on assign).
await deviceRoutes(app, db);
// Lane busy/free tracker: a camera's vehicle detection marks its bound lane busy
// (advisory barrier lights on the booth); auto-clears on a timeout. See lane-status.ts.
const laneStatus = new LaneStatus(db, app.log);
app.addHook("onClose", async () => laneStatus.stop());
// NB: the Hikvision Alarm Server routes are registered LOWER DOWN — after the read
// flows are constructed — because the ANPR bridge they carry depends on the
// SubscriptionFlow. See the hikvisionAlarmRoutes() call below the read-flow wiring.
// Live printer-status monitor: polls printers (paper/cover/cutter/offline) and
// pushes changes to the booth UI. setupRoutes() has already registered the
// built-in drivers the monitor needs. See wiki/concepts/printer-status-monitoring.md.
@@ -146,9 +160,13 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
// (+ sessions cache for durations). Gated on report:read. See routes/reports.ts.
await reportRoutes(app, db);
// Recycle bin: view / restore / purge soft-deleted master data (users/roles/subs/
// plans/tariffs). Gated on recyclebin:*. See routes/recycle-bin.ts, recycle-bin.ts.
await recycleBinRoutes(app, db);
// Live booth feed: server-pushed ledger + occupancy + printer-status over a
// single authenticated WebSocket (/api/ws). See routes/ws.ts.
await wsRoutes(app, db, deviceMonitor);
await wsRoutes(app, db, deviceMonitor, laneStatus);
// Entry/exit camera snapshots (BLOB-in-DB), read-only. See snapshot.ts.
await snapshotRoutes(app, db);
@@ -180,6 +198,19 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
});
app.addHook("onClose", async () => unsubscribeRead());
// ANPR bridge: a subscriber's plate, read off the lane camera's vehicle detection,
// admits them through the SAME gated SubscriptionFlow a QR/card scan uses (it emits a
// plate read onto the bus, which the dispatcher above turns into a gated entry/exit).
// Advisory + fail-soft + subscriber-only — never the sole reason a barrier opens. Needs
// the subscriptionFlow constructed just above. See anpr-entry.ts.
const anprBridge = new AnprBridge(db, visionClient, subscriptionFlow, app.log);
// Hikvision "Alarm Server" event push: the camera POSTs an EventNotificationAlert on
// each detected target (vehicle). Source-IP guarded + optional Digest; records the raw
// payload as a `kind:"alarm"` device_event, drives lane busy/free, AND hands a vehicle
// detection to the ANPR bridge above. See routes/hikvision-alarm.ts.
await hikvisionAlarmRoutes(app, db, laneStatus, anprBridge);
// Credential capture ("enroll a card"): lets the operator present an RFID card to a
// CHOSEN reader to populate a subscription credential, without blocking the other
// reader's live flow. Single-shot + TTL. See credential-capture.ts.
@@ -232,6 +263,18 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
logService.prune(); // once at startup
app.addHook("onClose", async () => clearInterval(pruneTimer));
// Recycle-bin retention sweep: auto-purge master data soft-deleted longer than the
// retention window (RECYCLE_BIN_RETENTION_DAYS, default 30; 0 = keep forever). Runs
// every 6h, unref'd, plus once at startup. See recycle-bin.ts.
const binTimer = setInterval(() => {
const purged = sweepExpired(db);
const total = Object.values(purged).reduce((a, b) => a + b, 0);
if (total > 0) app.log.info(`recycle-bin: auto-purged ${total} expired item(s) ${JSON.stringify(purged)}`);
}, 6 * 60 * 60 * 1000);
binTimer.unref();
if (retentionDays() > 0) sweepExpired(db); // once at startup
app.addHook("onClose", async () => clearInterval(binTimer));
const unsubscribeInput = deviceEvents.onInput((e) => {
// Record every input edge as unsigned telemetry, keyed to the device that fired
// (provenance). No lane — the pool-of-spaces model has none. The entry flow
+3 -2
View File
@@ -141,8 +141,9 @@ async function recognizePlate(
}
}
/** Build a live camera adapter from a resolved devices row, or null. */
function buildCamera(row: { driverId: string; config: unknown }): CameraDevice | null {
/** Build a live camera adapter from a resolved devices row, or null. Exported so the
* ANPR bridge (anpr-entry.ts) reuses the identical registry-build-or-null logic. */
export function buildCamera(row: { driverId: string; config: unknown }): CameraDevice | null {
const driver = registry.get(row.driverId);
if (!driver) return null;
try {
+4 -1
View File
@@ -108,7 +108,10 @@ export class SubscriptionFlow {
// that happens BEFORE we infer the entry/exit verb ("both" defers to entry).
const lane: FlowDirection = resolved.direction === "exit" ? "exit" : "entry";
const sub = this.#db.select().from(subscriptions).where(eq(subscriptions.id, m.subscriptionId)).get();
if (!sub) return { accepted: false, reason: await this.#reject(m, lane, "sub.refused.notFound") };
// A soft-deleted (recycle-bin) subscription must NOT open the barrier — treat it as
// gone. (Its credential rows are kept for restore, so the dispatcher can still match
// it; the gate is here.)
if (!sub || sub.deletedAt) return { accepted: false, reason: await this.#reject(m, lane, "sub.refused.notFound") };
// Validity: active + within the coverage window.
const now = new Date().toISOString();
+46 -2
View File
@@ -112,6 +112,44 @@ function TicketInput({ onSubmit }: { onSubmit: (identity: string) => void }) {
);
}
/** One barrier light — green = free, red = busy (a vehicle is at the lane vicinity,
* from camera detection). Advisory only; it gates nothing. */
function BarrierLight({ label, busy }: { label: string; busy: boolean }) {
return (
<div
className={`flex items-center gap-2 rounded-term border px-3 py-2 ${
busy ? "border-term-red bg-term-red/10" : "border-term-green bg-term-green/10"
}`}
title={label}
>
{/* Barrier glyph: a post + an arm. Colour carries the state. */}
<svg viewBox="0 0 24 24" className={`h-5 w-5 ${busy ? "text-term-red" : "text-term-green"}`} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
<line x1="5" y1="21" x2="5" y2="9" />
<line x1="5" y1="10" x2="21" y2="6" />
<circle cx="5" cy="7" r="1.6" fill="currentColor" stroke="none" />
</svg>
<div className="leading-tight">
<div className="text-[10px] uppercase tracking-wider text-term-muted">{label}</div>
<div className={`text-xs font-bold ${busy ? "text-term-red" : "text-term-green"}`}>
{busy ? "●" : "○"}
</div>
</div>
</div>
);
}
/** The two lane barrier lights (entry / exit) fed by the live lane-status. */
function LaneIndicators() {
const { t } = useTranslation();
const lanes = useLiveStore((s) => s.lanes);
return (
<div className="flex items-center gap-2">
<BarrierLight label={t("booth.laneEntry")} busy={lanes?.entry ?? false} />
<BarrierLight label={t("booth.laneExit")} busy={lanes?.exit ?? false} />
</div>
);
}
export function BoothScreen() {
const { t } = useTranslation();
// The site-wide shift drives the log scope: the feed shows ONLY the open shift's
@@ -198,10 +236,16 @@ export function BoothScreen() {
return (
<div className="grid h-full grid-cols-1 gap-3 lg:grid-cols-[minmax(320px,1fr)_2fr] lg:grid-rows-[auto_1fr]">
{/* Ticket input spans both columns at the top — the operator's primary action. */}
{/* Ticket input spans both columns at the top — the operator's primary action.
The lane barrier lights sit beside it (live vehicle-detection busy/free). */}
<div className="lg:col-span-2">
<Panel title={t("booth.processTicket")}>
<TicketInput onSubmit={setActiveTicket} />
<div className="flex flex-wrap items-center gap-3">
<div className="min-w-[260px] flex-1">
<TicketInput onSubmit={setActiveTicket} />
</div>
<LaneIndicators />
</div>
</Panel>
</div>
+170
View File
@@ -0,0 +1,170 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
ApiError,
can,
fetchRecycleBin,
purgeRecycleItem,
restoreRecycleItem,
type RecycleBinItem,
type RecycleKind,
type SessionUser,
} from "./api.js";
import { qk } from "./lib/query.js";
import { formatRelativeDateTime } from "./lib/format.js";
import { Modal } from "./ui/Modal.js";
// Recycle bin — the way back from an accidental delete. Lists everything soft-deleted
// across users/roles/subscriptions/plans/tariffs; an admin can Restore (back to its
// catalog) or Purge (permanent). Items auto-purge after the retention window. Gated by
// recyclebin:* (read to view, update to restore, delete to purge). See
// apps/server/src/recycle-bin.ts, wiki/concepts/soft-delete.md.
const KIND_KEY: Record<RecycleKind, string> = {
user: "recycleBin.kind.user",
role: "recycleBin.kind.role",
subscription: "recycleBin.kind.subscription",
plan: "recycleBin.kind.plan",
tariff: "recycleBin.kind.tariff",
};
export function RecycleBin({ user }: { user: SessionUser | null }) {
const { t } = useTranslation();
const qc = useQueryClient();
const binQ = useQuery({ queryKey: qk.recycleBin, queryFn: fetchRecycleBin });
const canRestore = can(user, "recyclebin:update");
const canPurge = can(user, "recyclebin:delete");
const [error, setError] = useState<string | null>(null);
const [purging, setPurging] = useState<RecycleBinItem | null>(null);
const onError = (e: unknown) => setError(e instanceof ApiError ? e.message : (e as Error).message);
const invalidate = () => {
void qc.invalidateQueries({ queryKey: qk.recycleBin });
// A restore/purge can change any catalog — refresh the ones a restore touches.
for (const key of [["users"], ["roles"], ["subscriptions"], ["subscription-plans"], ["tariff"]]) {
void qc.invalidateQueries({ queryKey: key });
}
};
const restoreM = useMutation({
mutationFn: ({ kind, id }: { kind: RecycleKind; id: string }) => restoreRecycleItem(kind, id),
onSuccess: invalidate,
onError,
});
const purgeM = useMutation({
mutationFn: ({ kind, id }: { kind: RecycleKind; id: string }) => purgeRecycleItem(kind, id),
onSuccess: () => {
setPurging(null);
invalidate();
},
onError: (e) => {
setPurging(null);
onError(e);
},
});
const items = binQ.data?.items ?? [];
const retentionDays = binQ.data?.retentionDays ?? 0;
return (
<div className="mx-auto max-w-4xl">
<div className="mb-3 flex items-center gap-3">
<h1 className="text-base font-bold uppercase tracking-widest text-term-amber">
{t("recycleBin.title")}
</h1>
{retentionDays > 0 && (
<span className="text-[12px] text-term-muted">
{t("recycleBin.retentionNote", { days: retentionDays })}
</span>
)}
</div>
{error && <p className="mb-2 text-[12px] text-term-red">{error}</p>}
{binQ.isLoading && <p className="text-term-muted">{t("common.loading")}</p>}
{!binQ.isLoading && items.length === 0 ? (
<p className="rounded-term border border-term-border bg-term-panel p-6 text-center text-term-muted">
{t("recycleBin.empty")}
</p>
) : (
<table className="w-full text-[13px]">
<thead>
<tr className="border-b border-term-border text-left text-[11px] uppercase tracking-wider text-term-muted">
<th className="py-1.5 pr-3">{t("recycleBin.col.type")}</th>
<th className="py-1.5 pr-3">{t("recycleBin.col.item")}</th>
<th className="py-1.5 pr-3">{t("recycleBin.col.deleted")}</th>
<th className="py-1.5 text-right">{t("recycleBin.col.actions")}</th>
</tr>
</thead>
<tbody>
{items.map((it) => (
<tr key={`${it.kind}:${it.id}`} className="border-b border-term-border/50">
<td className="py-1.5 pr-3">
<span className="rounded-term border border-term-border px-1.5 py-0.5 text-[11px] text-term-muted">
{t(KIND_KEY[it.kind])}
</span>
</td>
<td className="py-1.5 pr-3 text-term-text">{it.label}</td>
<td className="py-1.5 pr-3 text-term-muted">
{formatRelativeDateTime(it.deletedAt, t)}
</td>
<td className="py-1.5 text-right">
{canRestore && (
<button
type="button"
className="btn btn-sm"
disabled={restoreM.isPending}
onClick={() => {
setError(null);
restoreM.mutate({ kind: it.kind, id: it.id });
}}
>
{t("recycleBin.restore")}
</button>
)}
{canPurge && (
<button
type="button"
className="btn btn-sm btn-ghost ml-1 text-term-red"
onClick={() => {
setError(null);
setPurging(it);
}}
>
{t("recycleBin.purge")}
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
)}
{purging && (
<Modal open onClose={() => setPurging(null)} title={t("recycleBin.purgeConfirmTitle")}>
<p className="text-[13px] text-term-text">
{t("recycleBin.purgeConfirmBody", { label: purging.label })}
</p>
<p className="mt-1 text-[12px] text-term-red">{t("recycleBin.purgeIrreversible")}</p>
<div className="mt-3 flex justify-end gap-2">
<button type="button" className="btn btn-sm btn-ghost" onClick={() => setPurging(null)}>
{t("common.cancel")}
</button>
<button
type="button"
className="btn btn-sm btn-danger"
disabled={purgeM.isPending}
onClick={() => purgeM.mutate({ kind: purging.kind, id: purging.id })}
>
{t("recycleBin.purge")}
</button>
</div>
</Modal>
)}
</div>
);
}
+38 -8
View File
@@ -331,11 +331,13 @@ function DeviceForm({
// Pre-fill scalar config fields from the existing assignment when editing.
// (relays/controllerId/relay are model fields handled by their own state below.)
const [config, setConfig] = useState<Record<string, string | number>>(() => {
// Booleans are kept as real booleans (a checkbox field) — older saved configs may
// have stored a boolean as the string "true"/"false"; normalize those on load.
const [config, setConfig] = useState<Record<string, string | number | boolean>>(() => {
if (!editCfg) return {};
const out: Record<string, string | number> = {};
const out: Record<string, string | number | boolean> = {};
for (const [k, v] of Object.entries(editCfg)) {
if (typeof v === "string" || typeof v === "number") out[k] = v;
if (typeof v === "string" || typeof v === "number" || typeof v === "boolean") out[k] = v;
}
return out;
});
@@ -415,9 +417,16 @@ function DeviceForm({
}
/** Scalar config the user entered, merged over driver defaults (for test/push-IP). */
function mergedScalarConfig(): Record<string, string | number> {
const out: Record<string, string | number> = {};
function mergedScalarConfig(): Record<string, string | number | boolean> {
const out: Record<string, string | number | boolean> = {};
for (const f of selected?.configFields ?? []) {
// Boolean (checkbox) fields persist a REAL boolean — always (so toggling one OFF
// on an edit actually writes false), defaulting to the field default or false.
if (f.type === "boolean") {
const cur = config[f.key];
out[f.key] = typeof cur === "boolean" ? cur : Boolean(cur ?? f.default ?? false);
continue;
}
const v = config[f.key] ?? (f.default as string | number | undefined);
if (v !== undefined && v !== "") out[f.key] = v;
}
@@ -560,7 +569,27 @@ function DeviceForm({
</div>
)}
{selected.configFields.map((f) => (
{selected.configFields.map((f) =>
f.type === "boolean" ? (
// Boolean config field → a real checkbox (stores a true/false boolean, not
// the string "true"). The label sits beside the box, with the help below.
<label key={f.key} className="my-2 flex max-w-sm items-start gap-2 rounded-term border border-term-border bg-term-bg p-2 text-[12px]">
<input
type="checkbox"
className="mt-0.5"
checked={Boolean(config[f.key] ?? f.default ?? false)}
onChange={(e) => {
const v = e.target.checked;
setConfig((c) => ({ ...c, [f.key]: v }));
resetStatus();
}}
/>
<span>
<span className="font-semibold text-term-text">{f.label}</span>
{f.help && <span className="hint mt-0.5 block">{f.help}</span>}
</span>
</label>
) : (
<div key={f.key} className="field my-2 max-w-sm">
<label className="label">
{f.label}
@@ -586,7 +615,7 @@ function DeviceForm({
<input
className="input"
type={f.type === "secret" ? "password" : f.type === "number" || f.type === "port" ? "number" : "text"}
value={config[f.key] ?? (f.default as string | number | undefined) ?? ""}
value={(config[f.key] ?? (f.default as string | number | undefined) ?? "") as string | number}
placeholder={f.help}
onChange={(e) => {
const v = e.target.value;
@@ -596,7 +625,8 @@ function DeviceForm({
/>
)}
</div>
))}
),
)}
{/* CONTROLLER: the relay map — which relay opens which direction + entry button. */}
{isController && <RelayEditor relays={relays} onChange={setRelays} />}
+15
View File
@@ -26,6 +26,7 @@ export function SiteSettings({ canEdit }: { canEdit: boolean }) {
const [meta, setMeta] = useState<Record<string, string>>({});
const [exitVoucherDefault, setExitVoucherDefault] = useState(false);
const [reserveSubs, setReserveSubs] = useState(false);
const [anprEntry, setAnprEntry] = useState(true);
const [msg, setMsg] = useState<string | null>(null);
function reload() {
@@ -38,6 +39,7 @@ export function SiteSettings({ canEdit }: { canEdit: boolean }) {
setCapInput(c.capacity == null ? "" : String(c.capacity));
setExitVoucherDefault(c.exitVoucherDefault);
setReserveSubs(c.reserveSubscriberSpots);
setAnprEntry(c.anprEntryEnabled);
const m: Record<string, string> = {};
for (const { key } of META_FIELDS) m[key] = c[key] == null ? "" : String(c[key]);
setMeta(m);
@@ -52,6 +54,7 @@ export function SiteSettings({ canEdit }: { canEdit: boolean }) {
capacity: raw === "" ? null : Math.round(Number(raw)),
exitVoucherDefault,
reserveSubscriberSpots: reserveSubs,
anprEntryEnabled: anprEntry,
};
// Send each metadata field; "" → null is applied server-side.
for (const { key } of META_FIELDS) (patch as Record<string, string | null>)[key] = meta[key] ?? "";
@@ -112,6 +115,18 @@ export function SiteSettings({ canEdit }: { canEdit: boolean }) {
<span className="hint block">{t("site.reserveSubsHint")}</span>
</span>
</label>
<label className="flex items-start gap-2 text-[12px] text-term-text">
<input
type="checkbox"
className="mt-0.5 accent-term-amber"
checked={anprEntry}
onChange={(e) => setAnprEntry(e.target.checked)}
/>
<span>
{t("site.anprEntry")}
<span className="hint block">{t("site.anprEntryHint")}</span>
</span>
</label>
<div className="border-t border-term-border pt-3 text-[11px] uppercase tracking-wider text-term-muted">
{t("site.parkDetails")}
</div>
+35
View File
@@ -368,6 +368,39 @@ export function reportCsvUrl(from: string, to: string, bucket: ReportBucket): st
return apiUrl(`/api/reports/summary.csv?${qs}`);
}
// --- Recycle bin (soft-deleted master data) ------------------------------
export type RecycleKind = "user" | "role" | "subscription" | "plan" | "tariff";
export interface RecycleBinItem {
kind: RecycleKind;
id: string;
label: string;
deletedAt: string;
deletedBy: string | null;
}
export interface RecycleBin {
items: RecycleBinItem[];
retentionDays: number;
}
/** Everything currently in the recycle bin + the retention window (days). */
export function fetchRecycleBin(): Promise<RecycleBin> {
return apiFetch<RecycleBin>("/api/recycle-bin");
}
/** Restore a soft-deleted item (back to its catalog). 409 if a live row would collide. */
export function restoreRecycleItem(kind: RecycleKind, id: string): Promise<{ restored: boolean }> {
return apiFetch<{ restored: boolean }>(`/api/recycle-bin/${kind}/${encodeURIComponent(id)}/restore`, {
method: "POST",
});
}
/** Permanently purge a soft-deleted item. Irreversible. */
export function purgeRecycleItem(kind: RecycleKind, id: string): Promise<void> {
return apiFetch<void>(`/api/recycle-bin/${kind}/${encodeURIComponent(id)}`, { method: "DELETE" });
}
export interface BackendIpCandidate {
ip: string;
iface: string;
@@ -910,6 +943,8 @@ export interface SiteConfig {
subscriptionMonthlyPriceMinor: number | null;
/** Reserve a spot for each active subscriber's car(s) in the occupancy/full gate. */
reserveSubscriberSpots: boolean;
/** Master switch for the ANPR subscriber-entry bridge (auto-open on a plate read). */
anprEntryEnabled: boolean;
parkName: string | null;
operatorName: string | null;
/** NIUS — Albanian tax/identification number. */
+23
View File
@@ -56,6 +56,7 @@ export const en: Catalog = {
roles: "Roles",
shifts: "Shifts",
reports: "Reports",
recycleBin: "Recycle bin",
logs: "Logs",
},
status: {
@@ -94,6 +95,8 @@ export const en: Catalog = {
booth: {
processTicket: "Process ticket",
scanPlaceholder: "Scan or type ticket number…",
laneEntry: "Entry",
laneExit: "Exit",
open: "Open",
occupancy: "Occupancy",
occUnavailable: "occupancy unavailable",
@@ -529,6 +532,8 @@ export const en: Catalog = {
printExitHint: "(booth far from exit → customer self-exits with a voucher)",
reserveSubs: "Reserve subscriber spots",
reserveSubsHint: "Hold a spot for each active subscriber's car(s) even when they're not parked — transients see 'full' sooner. Off: only cars inside count (handle overflow by valet).",
anprEntry: "Auto-open for subscriber plates (ANPR)",
anprEntryHint: "When on, a subscriber's plate read by a lane camera opens the barrier through the normal subscription gate. Off: subscribers must use their card/QR. Plate snapshots are still recorded either way.",
parkDetails: "Park details (optional — shown on tickets/receipts)",
save: "Save",
saved: "Saved.",
@@ -712,6 +717,24 @@ export const en: Catalog = {
subCars: "Cars covered",
},
},
recycleBin: {
title: "Recycle bin",
retentionNote: "Deleted items are kept for {{days}} days, then permanently removed.",
empty: "Nothing deleted. Items you delete appear here, recoverable until they expire.",
col: { type: "Type", item: "Item", deleted: "Deleted", actions: "" },
kind: {
user: "User",
role: "Role",
subscription: "Subscription",
plan: "Plan",
tariff: "Tariff",
},
restore: "Restore",
purge: "Purge",
purgeConfirmTitle: "Purge permanently?",
purgeConfirmBody: "Permanently delete “{{label}}”? It cannot be restored after this.",
purgeIrreversible: "This is irreversible.",
},
logs: {
title: "System logs",
refresh: "Refresh",
+23
View File
@@ -58,6 +58,7 @@ export const sq = {
roles: "Rolet",
shifts: "Turnet",
reports: "Raportet",
recycleBin: "Koshi",
logs: "Loget",
},
status: {
@@ -96,6 +97,8 @@ export const sq = {
booth: {
processTicket: "Proceso biletën",
scanPlaceholder: "Skano ose shkruaj numrin e biletës…",
laneEntry: "Hyrje",
laneExit: "Dalje",
open: "Hap",
occupancy: "Prania",
occUnavailable: "zënia e padisponueshme",
@@ -540,6 +543,8 @@ export const sq = {
printExitHint: "(klienti skanon biletën në dalje)",
reserveSubs: "Rezervo vendet e abonentëve",
reserveSubsHint: "Mban një vend për makinat e çdo abonenti aktiv edhe kur nuk janë të parkuar — kalimtarët e shohin 'plot' më shpejt. Joaktiv: numërohen vetëm makinat brenda (mbingarkesa menaxhohet me parkim manual).",
anprEntry: "Hapje automatike për targat e abonentëve (ANPR)",
anprEntryHint: "Kur është aktiv, targa e një abonenti e lexuar nga kamera e korsisë hap barrierën përmes portës normale të abonimit. Joaktiv: abonentët duhet të përdorin kartën/QR-në. Fotot e targave regjistrohen gjithsesi.",
parkDetails: "Të dhënat e parkimit (opsionale — shfaqen në bileta/fatura)",
save: "Ruaj",
saved: "U ruajt.",
@@ -726,6 +731,24 @@ export const sq = {
subCars: "Makina të mbuluara",
},
},
recycleBin: {
title: "Koshi",
retentionNote: "Artikujt e fshirë mbahen për {{days}} ditë, pastaj hiqen përgjithmonë.",
empty: "Asgjë e fshirë. Artikujt që fshini shfaqen këtu, të rikuperueshëm derisa të skadojnë.",
col: { type: "Lloji", item: "Artikulli", deleted: "Fshirë", actions: "" },
kind: {
user: "Përdorues",
role: "Rol",
subscription: "Abonim",
plan: "Plan",
tariff: "Tarifë",
},
restore: "Rikthe",
purge: "Fshi përfundimisht",
purgeConfirmTitle: "Të fshihet përfundimisht?",
purgeConfirmBody: "Të fshihet përgjithmonë “{{label}}”? Nuk mund të rikthehet pas kësaj.",
purgeIrreversible: "Ky veprim është i pakthyeshëm.",
},
logs: {
title: "Loget e sistemit",
refresh: "Rifresko",
+13 -1
View File
@@ -10,6 +10,12 @@ import type { DeviceStatus, LedgerEvent, Occupancy } from "../api.js";
/** Connection state of the booth WebSocket, for a status indicator in the UI. */
export type WsStatus = "connecting" | "open" | "closed";
/** Per-lane busy/free from camera vehicle detection (advisory barrier lights). */
export interface LaneStatus {
entry: boolean; // true = busy
exit: boolean; // true = busy
}
/** Cap the in-memory live feed so a long-running booth session can't grow it
* unbounded — the full history is always available via the /api/events query. */
const MAX_FEED = 200;
@@ -23,6 +29,8 @@ interface LiveState {
/** Live device status keyed by device id (for the footer): set from the WS
* hello snapshot, then upserted per device on each device-status push. */
devices: Record<string, DeviceStatus>;
/** Per-lane busy/free (camera vehicle detection). Null until the first WS hello. */
lanes: LaneStatus | null;
setStatus: (s: WsStatus) => void;
setOccupancy: (o: Occupancy) => void;
pushEvent: (e: LedgerEvent) => void;
@@ -30,6 +38,8 @@ interface LiveState {
setDevices: (list: DeviceStatus[]) => void;
/** Upsert one device's status (a device-status push). */
upsertDevice: (d: DeviceStatus) => void;
/** Set lane busy/free (WS hello + each lane-status push). */
setLanes: (l: LaneStatus) => void;
reset: () => void;
}
@@ -45,6 +55,7 @@ export const useLiveStore = create<LiveState>((set) => ({
occupancy: null,
feed: [],
devices: {},
lanes: null,
setStatus: (status) => set({ status }),
setOccupancy: (occupancy) => set({ occupancy }),
pushEvent: (e) =>
@@ -54,5 +65,6 @@ export const useLiveStore = create<LiveState>((set) => ({
})),
setDevices: (list) => set({ devices: byId(list) }),
upsertDevice: (d) => set((s) => ({ devices: { ...s.devices, [d.deviceId]: d } })),
reset: () => set({ status: "connecting", occupancy: null, feed: [], devices: {} }),
setLanes: (lanes) => set({ lanes }),
reset: () => set({ status: "connecting", occupancy: null, feed: [], devices: {}, lanes: null }),
}));
+1
View File
@@ -29,4 +29,5 @@ export const qk = {
deviceStatus: ["device-status"] as const,
report: (from: string, to: string, bucket: string) =>
["report", from, to, bucket] as const,
recycleBin: ["recycle-bin"] as const,
} as const;
+8 -4
View File
@@ -2,7 +2,7 @@ import { useEffect, useRef } from "react";
import { useQueryClient } from "@tanstack/react-query";
import type { DeviceStatus, LedgerEvent, Occupancy } from "../api.js";
import { qk } from "./query.js";
import { useLiveStore } from "./live-store.js";
import { useLiveStore, type LaneStatus } from "./live-store.js";
import { wsUrl } from "./origin.js";
// Booth WebSocket client. Opens ONE socket to /api/ws and turns server pushes into
@@ -14,15 +14,16 @@ import { wsUrl } from "./origin.js";
/** Server → client message shapes (mirror routes/ws.ts OutMsg). */
type WsMessage =
| { kind: "hello"; occupancy: Occupancy; devices: DeviceStatus[] }
| { kind: "hello"; occupancy: Occupancy; devices: DeviceStatus[]; lanes: LaneStatus }
| { kind: "ledger"; event: LedgerEvent; occupancy: Occupancy }
| { kind: "printer-status"; event: unknown }
| { kind: "device-status"; event: DeviceStatus };
| { kind: "device-status"; event: DeviceStatus }
| { kind: "lane-status"; lanes: LaneStatus };
export function useLiveFeed(): void {
const qc = useQueryClient();
const { setStatus, setOccupancy, pushEvent, setDevices, upsertDevice } = useLiveStore();
const { setStatus, setOccupancy, pushEvent, setDevices, upsertDevice, setLanes } = useLiveStore();
// Hold the socket + reconnect timer across renders; guard against StrictMode
// double-invoke and unmount.
const sockRef = useRef<WebSocket | null>(null);
@@ -54,8 +55,11 @@ export function useLiveFeed(): void {
setOccupancy(msg.occupancy);
// Initial device-status snapshot for the footer.
if (Array.isArray(msg.devices)) setDevices(msg.devices);
if (msg.lanes) setLanes(msg.lanes);
} else if (msg.kind === "device-status") {
upsertDevice(msg.event);
} else if (msg.kind === "lane-status") {
setLanes(msg.lanes);
} else if (msg.kind === "ledger") {
setOccupancy(msg.occupancy);
pushEvent(msg.event);
+16
View File
@@ -30,6 +30,7 @@ import { UsersManager } from "./UsersManager.js";
import { RolesManager } from "./RolesManager.js";
import { ShiftsHistory } from "./ShiftsHistory.js";
import { LogsViewer } from "./LogsViewer.js";
import { RecycleBin } from "./RecycleBin.js";
// Reports pulls in Recharts (~heavy) — lazy-loaded so it stays OUT of the booth's
// initial bundle and only downloads when an admin opens /setup/reports.
const Reports = lazy(() => import("./Reports.js").then((m) => ({ default: m.Reports })));
@@ -88,6 +89,7 @@ function SetupLayout() {
{show("site:read") && <SetupTab to="/setup/site" label={t("nav.site")} />}
{show("user:read") && <SetupTab to="/setup/users" label={t("nav.users")} />}
{show("role:read") && <SetupTab to="/setup/roles" label={t("nav.roles")} />}
{show("recyclebin:read") && <SetupTab to="/setup/recycle-bin" label={t("nav.recycleBin")} />}
{show("log:read") && <SetupTab to="/setup/logs" label={t("nav.logs")} />}
</nav>
<Outlet />
@@ -387,6 +389,7 @@ function RootLayout() {
show("site:read") ||
show("user:read") ||
show("role:read") ||
show("recyclebin:read") ||
show("shift:read")) && <NavLink to="/setup" label={t("nav.setup")} />}
</nav>
<div className="ml-auto flex items-center gap-3">
@@ -513,6 +516,7 @@ const SETUP_TABS: { to: string; perm: Permission }[] = [
{ to: "/setup/site", perm: "site:read" },
{ to: "/setup/users", perm: "user:read" },
{ to: "/setup/roles", perm: "role:read" },
{ to: "/setup/recycle-bin", perm: "recyclebin:read" },
{ to: "/shifts", perm: "shift:read" },
{ to: "/setup/logs", perm: "log:read" },
];
@@ -610,6 +614,17 @@ const rolesRoute = createRoute({
// (Shift history lives at the standalone /shifts route — see shiftRoute. It was
// removed as a Setup tab; /setup/shifts and the old /shift both redirect there.)
// Recycle bin — restore/purge soft-deleted master data. Gated by recyclebin:read.
const recycleBinRoute = createRoute({
getParentRoute: () => setupRoute,
path: "recycle-bin",
beforeLoad: ({ context }) => requirePerm("recyclebin:read")(context),
component: function RecycleBinRoute() {
const { user } = rootRoute.useRouteContext();
return <RecycleBin user={user} />;
},
});
// Diagnostic logs. Gated by log:read (an admin/diagnostic permission).
const logsRoute = createRoute({
getParentRoute: () => setupRoute,
@@ -635,6 +650,7 @@ const routeTree = rootRoute.addChildren([
siteRoute,
usersRoute,
rolesRoute,
recycleBinRoute,
logsRoute,
]),
]);
+6
View File
@@ -9,6 +9,12 @@ export default defineConfig({
plugins: [react(), tailwindcss()],
server: {
port: 5173,
// Bind all interfaces so the dev SPA is reachable from other LAN devices
// (phone over wifi, etc.) at http://<host-lan-ip>:5173 — not just localhost.
// NB: loading from a non-localhost origin means the booth WebSocket (/api/ws)
// sends Origin: http://<host-lan-ip>:5173, which the backend's WS_ALLOWED_ORIGINS
// must include or the live feed is rejected. See apps/server/.env(.example).
host: "0.0.0.0",
proxy: {
// Use 127.0.0.1 (not "localhost") so the proxy never tries IPv6 ::1
// first and stall — the backend binds IPv4. Avoids slow/hung requests,
+19
View File
@@ -0,0 +1,19 @@
-- Soft delete (recycle bin) for accidental hard-deletes of master data. Adds a nullable
-- `deleted_at` (ISO-8601; null = live) + `deleted_by` (the admin user id) to the mutable
-- master-data tables. A DELETE now stamps these instead of removing the row; restore
-- clears them; an admin purge (or the retention sweep) does the real DELETE. The signed
-- append-only ledger is NOT touched — it has no delete path and is out of scope here.
--
-- All additive ALTER ADD COLUMN — backward-compatible (existing rows: deleted_at null =
-- live). SQLite ADD COLUMN is in-place. Subscription PLANS are versioned (many rows per
-- plan_id); a soft-delete stamps every version row of that plan_id together.
ALTER TABLE `users` ADD `deleted_at` text;--> statement-breakpoint
ALTER TABLE `users` ADD `deleted_by` text;--> statement-breakpoint
ALTER TABLE `roles` ADD `deleted_at` text;--> statement-breakpoint
ALTER TABLE `roles` ADD `deleted_by` text;--> statement-breakpoint
ALTER TABLE `subscriptions` ADD `deleted_at` text;--> statement-breakpoint
ALTER TABLE `subscriptions` ADD `deleted_by` text;--> statement-breakpoint
ALTER TABLE `subscription_plans` ADD `deleted_at` text;--> statement-breakpoint
ALTER TABLE `subscription_plans` ADD `deleted_by` text;--> statement-breakpoint
ALTER TABLE `tariffs` ADD `deleted_at` text;--> statement-breakpoint
ALTER TABLE `tariffs` ADD `deleted_by` text;
@@ -0,0 +1,5 @@
-- Site master switch for the ANPR subscriber-entry bridge (anpr-entry.ts). Additive
-- ALTER ADD COLUMN — backward-compatible. Default 1 (ON) so existing installs keep the
-- now-live auto-open-for-subscriber-plates behaviour after upgrade. The toggle gates ONLY
-- the barrier-driving bridge; advisory snapshot-ANPR + lane busy/free are unaffected.
ALTER TABLE `site_config` ADD `anpr_entry_enabled` integer DEFAULT 1 NOT NULL;
+14
View File
@@ -85,6 +85,20 @@
"when": 1781885400000,
"tag": "0011_subscription_plan_v2",
"breakpoints": true
},
{
"idx": 12,
"version": "6",
"when": 1781885500000,
"tag": "0012_soft_delete",
"breakpoints": true
},
{
"idx": 13,
"version": "6",
"when": 1781885600000,
"tag": "0013_anpr_entry_toggle",
"breakpoints": true
}
]
}
+1 -1
View File
@@ -5,7 +5,7 @@ import * as schema from "./schema.js";
export * from "./schema.js";
// Re-export the query helpers consumers need, so they don't depend on
// drizzle-orm directly (it's an implementation detail of this package).
export { eq, and, asc, desc, gte, lte, sql } from "drizzle-orm";
export { eq, ne, and, or, asc, desc, gte, lte, isNull, isNotNull, inArray, sql } from "drizzle-orm";
/**
* Open the local SQLite database in WAL mode. WAL allows many concurrent readers
+36
View File
@@ -29,6 +29,11 @@ export const roles = sqliteTable("roles", {
createdAt: text("created_at")
.notNull()
.default(sql`(current_timestamp)`),
// Soft delete (recycle bin): ISO instant the row was deleted, null = live; the admin
// user id who deleted it. A DELETE stamps these; restore clears them; purge/retention
// does the real row removal. See wiki/concepts/soft-delete.md.
deletedAt: text("deleted_at"),
deletedBy: text("deleted_by"),
});
/** The role→permission grid. One row per granted `resource:action` permission.
@@ -78,6 +83,11 @@ export const users = sqliteTable("users", {
createdAt: text("created_at")
.notNull()
.default(sql`(current_timestamp)`),
// Soft delete (recycle bin) — see roles.deletedAt. NB: `username` stays UNIQUE across
// live AND deleted rows, so creating a new user reusing a deleted user's name is
// blocked until that row is restored or purged (the route returns a clear 409).
deletedAt: text("deleted_at"),
deletedBy: text("deleted_by"),
});
// --- The signed business ledger (formerly `events`) ----------------------
@@ -226,6 +236,16 @@ export const siteConfig = sqliteTable("site_config", {
reserveSubscriberSpots: integer("reserve_subscriber_spots", { mode: "boolean" })
.notNull()
.default(false),
/** Site master switch for the ANPR subscriber-entry BRIDGE (anpr-entry.ts): when ON
* (default), a subscriber's plate read off a lane camera's vehicle detection opens the
* barrier through the normal gated subscription flow. When OFF, the bridge emits no read
* (subscribers fall back to their card/QR). This gates ONLY the barrier-driving bridge —
* advisory snapshot-ANPR recording and lane busy/free are unaffected. Read LIVE per event
* so toggling takes effect with no restart. Default ON because the feature is already
* live. Stored 0/1. See wiki/concepts/lane-presence-and-anpr-entry.md. */
anprEntryEnabled: integer("anpr_entry_enabled", { mode: "boolean" })
.notNull()
.default(true),
/** IANA timezone the site operates in (e.g. "Europe/Tirane"). Used to evaluate a
* tariff's wall-clock pricing windows (happy hour / night / seasonal). COPIED into
* each published tariff version's structure.tz so the windows are frozen/immutable
@@ -258,6 +278,10 @@ export const tariffs = sqliteTable("tariffs", {
createdAt: text("created_at")
.notNull()
.default(sql`(current_timestamp)`),
// Soft delete (recycle bin) — see roles.deletedAt. Stamps the rate-card row; its
// immutable tariff_versions are kept (referenced for repricing) and ride along.
deletedAt: text("deleted_at"),
deletedBy: text("deleted_by"),
});
export const tariffVersions = sqliteTable("tariff_versions", {
@@ -314,6 +338,12 @@ export const subscriptionPlans = sqliteTable("subscription_plans", {
createdAt: text("created_at")
.notNull()
.default(sql`(current_timestamp)`),
// Soft delete (recycle bin) — see roles.deletedAt. A plan is VERSIONED (many rows per
// plan_id); a soft-delete stamps every version row of the plan_id together, and the bin
// shows/restores the plan as one item. Distinct from `active=0` (retire = unsellable
// but kept in the catalog); deletedAt removes it from the catalog entirely.
deletedAt: text("deleted_at"),
deletedBy: text("deleted_by"),
});
export const subscriptions = sqliteTable("subscriptions", {
@@ -348,6 +378,12 @@ export const subscriptions = sqliteTable("subscriptions", {
createdAt: text("created_at")
.notNull()
.default(sql`(current_timestamp)`),
// Soft delete (recycle bin) — see roles.deletedAt. Distinct from `status: "revoked"`
// (a domain state that BARS the subscriber but keeps it visible); deletedAt removes it
// from the catalog entirely, recoverable from the bin. Child credential/plate rows are
// kept and restored with it.
deletedAt: text("deleted_at"),
deletedBy: text("deleted_by"),
});
// A subscription's credentials (RF tag/chip/card, or QR). Either opens the barrier.
+44 -2
View File
@@ -95,13 +95,55 @@ const channelField: ConfigField = {
const cameraConfigFields = [hostField, portField(80), usernameField, passwordField, channelField];
// Hikvision "Alarm Server" PUSH config. The newer firmware (Event → Smart/VCA →
// "Detection Target: Human/Vehicle", Notify Surveillance Center, Alarm Settings →
// Alarm Server) HTTP-POSTs an EventNotificationAlert to a URL we host every time the
// chosen target is detected — same shape as the Dingtian Input Link push. When enabled,
// the admin points the camera's Alarm Server at /api/devices/hikvision/:deviceId/event
// and we record what it sends. See routes/hikvision-alarm.ts, wiki/entities/lpr-camera.md.
const alarmPushFields: ConfigField[] = [
{
key: "alarmPushEnabled",
label: "Alarm Server push (Event → vehicle)",
type: "boolean",
required: false,
default: false,
help: "The camera POSTs each detected event to us (set its Alarm Settings → Alarm Server to this backend). No polling.",
},
{
key: "pushUser",
label: "Alarm push username (optional)",
type: "string",
required: false,
help: "Only if the camera's Alarm Server is set to authenticate (HTTP Digest). Leave blank to accept by source-IP only.",
},
{
key: "pushPassword",
label: "Alarm push password (optional)",
type: "secret",
required: false,
help: "Paired with the username above for Digest auth on the push. Leave blank for source-IP-only.",
},
{
key: "skipSourceIpCheck",
label: "Don't verify push source IP",
type: "boolean",
required: false,
default: false,
help: "Accept pushes regardless of the source IP. Needed when the network rewrites the inbound source address (e.g. WSL mirrored mode reports the host's own IP, not the camera's), which would otherwise reject every push. Leave OFF on a normal LAN.",
},
];
export const hikvisionDriver: CameraDriver = {
id: "hikvision",
category: "camera",
label: "Hikvision camera",
description: "Hikvision snapshot via ISAPI (HTTP Digest).",
description: "Hikvision snapshot via ISAPI (HTTP Digest) + optional Alarm Server event push.",
transports: ["tcp-ip"],
configFields: cameraConfigFields,
// The camera PULLS snapshots, but with Alarm Server on it ALSO pushes events to us —
// so it may need the backend push IP at assign time (like the Dingtian).
pushesToBackend: true,
configFields: [...cameraConfigFields, ...alarmPushFields],
// ISAPI channel id: <channel><stream>, e.g. ch1 main = 101, ch2 main = 201.
create: (c) =>
new HttpCamera("hikvision", c, (ch) => `/ISAPI/Streaming/channels/${ch}01/picture`),
+4
View File
@@ -27,6 +27,7 @@ export const RESOURCES = [
"event", // the signed ledger feed + void
"report", // events feed, occupancy, future reports
"log", // application/diagnostic logs (app_logs) — view + retention
"recyclebin", // soft-deleted master data: view / restore / purge
] as const;
export type Resource = (typeof RESOURCES)[number];
@@ -56,6 +57,9 @@ export const PERMISSIONS: readonly Permission[] = [
"event:read", "event:void",
"report:read",
"log:read",
// Recycle bin: read (list soft-deleted items), update (restore), delete (purge). These
// are admin-grade — a restore can revive a privileged user/role, a purge is permanent.
"recyclebin:read", "recyclebin:update", "recyclebin:delete",
] as const;
/** The protected built-in role: non-deletable, non-editable, always = ALL
@@ -0,0 +1,127 @@
---
type: concept
tags: [parking, camera, anpr, subscription, lane, vision]
sources: []
updated: 2026-06-22
status: open
---
# Lane Presence & ANPR Subscriber Entry
Two related things a camera's vehicle detection feeds, worked out over a long field session
(2026-06-22, see [[lpr-camera]] for the camera-side saga + the corrected "it was the undrawn
detection area" conclusion):
1. **Lane busy/free** — BUILT. An advisory barrier light on the booth.
2. **ANPR subscriber entry** — BUILT (2026-06-22). A subscriber's plate, read from the lane camera,
drives their entry/exit through the EXISTING [[subscription]] flow. The "bridge" below.
The camera (Hik `DS-2CD1043G2-LIU`) only emits `VMD` events with `eventState=active` and a
`targetType` of `vehicle`/`human` — a coarse **presence** signal, never an identity. Everything
here is built on that, and on the camera's hard limits.
## What the camera actually gives us (measured)
- **Push only, no poll.** No ISAPI endpoint reports "is a car in the zone now"; the only live source
is the event push. We tried to force a steadier signal by flipping `notificationRecurrence`
`beginning → recurring` via ISAPI — the firmware **accepts the PUT but silently reverts** (locked to
`beginning` on this value line). So: notify-once-at-motion-start is all we get.
- **No leave signal.** The camera never sends an `inactive`/end event. Confirmed by config
(`notificationRecurrence: beginning`) AND a controlled in/out test.
- **Re-fire is MOVEMENT-driven, not steady.** Controlled test (call out enter/leave, correlate to the
event log): while a car MOVES, `active` repeats ~1–3 s apart; while it sits MOTIONLESS, gaps stretch
to ~15–25 s. Crucially the camera has **~no dwell lag** — it goes silent within ~1 s of the car
leaving (last event 16:15:17 vs car-left ~16:15:30).
## 1. Lane busy/free (BUILT)
`apps/server/src/lane-status.ts` (`LaneStatus`) + the hik-alarm handler + the booth WS. A `vehicle`
`active` event on a camera bound to entry/exit marks THAT lane busy and arms an auto-clear timer; the
booth shows two barrier lights beside the scan input (green=free, red=busy). **Advisory only — gates
nothing** (never blocks a ticket or opens a barrier; the standing rule).
- **"Free" is timeout-driven** (no leave signal). The TTL must exceed the still-car gap (~25 s) or a
parked car flickers free — so `LANE_BUSY_TTL_MS` default is **30 s** (started at a guessed 90 s,
briefly 5 s, then set to 30 s from the measured data). The camera's lack of dwell lag means 30 s
also clears promptly after departure.
- A `both`-direction camera marks both lanes. Pushed over the existing `/api/ws` (kind `lane-status`).
## 2. ANPR subscriber entry — THE BRIDGE (BUILT 2026-06-22)
> **"Bridge" = a HANDLER CLASS in `apps/server/src/anpr-entry.ts` (`AnprBridge`). NOT a new
> service / container / app.** It is in-process glue that calls things that ALREADY exist.
**As built:** `hikvision-alarm.ts`, on a `vehicle`/non-`inactive` push from an `anpr`-opted-in
camera, hands the deviceId to `AnprBridge.onVehicleDetected()` (fire-and-forget, never awaited on the
camera's 200). The bridge: debounce (camera-level, pre-snapshot) → `captureSnapshot` (fresh pull, via
the reused `snapshot.ts buildCamera`) → `vision.analyze` → entry confidence floor
(`VISION_ENTRY_MIN_CONFIDENCE`, 0.85) → normalize plate → **`subscriptionFlow.match()` (match BEFORE
emit)** → if a subscriber, `deviceEvents.emitRead({kind:"plate"})`; if not, record an advisory
`anpr-skip` device_event and stop. The existing `onRead → ReadDispatcher → SubscriptionFlow.run()`
then does the gated entry/exit + barrier open. Constructed in `server.ts` (the flows were reordered
above the hik-alarm registration so the bridge can take `subscriptionFlow`). Fail-soft throughout —
any snapshot/vision error degrades to the subscriber's card/QR, never throws into the push handler.
Two new env knobs: `VISION_ENTRY_MIN_CONFIDENCE` (0.85), `ANPR_DEBOUNCE_MS` (12_000). Covered by
`anpr-entry.test.ts` (7) + `hikvision-alarm.test.ts` wiring (3).
The goal (narrowed deliberately — see Rejected below): **a subscriber's plate, read by the lane
camera, admits them through the same gated flow a QR/card scan uses.** Scope was cut to subscribers
ONLY — no queue segmentation, no per-car tracking, no make/model, no ticket-button gating.
Almost everything already exists; the bridge is the one missing wire:
| Piece | Status |
| --- | --- |
| Camera vehicle event | ✅ [[lpr-camera]] (hik-alarm) |
| Pull a snapshot | ✅ `snapshot.ts` (`captureSnapshot`) |
| Read the plate | ✅ [[opencv-anpr-service]] `/analyze` (~50 ms on the DEV PC; appliance TBD) |
| Match a plate → subscriber | ✅ `subscription-flow.ts` `match()` + `subscription_plates` (`via:"plate"`) |
| Plate read → gated entry/exit | ✅ `read-dispatch.ts` + SubscriptionFlow (active/window/blocklist/car-count) |
| **Emit the plate onto the read bus** | ✅ `anpr-entry.ts` (`AnprBridge`) — on a vehicle push from an `anpr` camera it snapshots → analyzes → matches a subscriber → `emitRead({kind:"plate"})`. (Built 2026-06-22.) |
**The bridge logic:** on a camera `vehicle`/`active` event from an **opt-in** camera (`config.anpr`),
snapshot → `vision.analyze` → if a plate clears a **HIGH** confidence floor → **debounce** → emit
`DeviceReadEvent{kind:"plate", value, deviceId}`. The existing dispatcher + flow do the rest.
### Decisions settled with the user (2026-06-22)
- **Both directions.** Entry- and exit-bound cameras both work; the dispatcher infers the verb from
the camera's bound relay direction (an entry camera → entry, exit → exit). No per-read inference.
- **High confidence required.** A barrier-driving read needs a stricter bar than the advisory-record
floor — a NEW `VISION_ENTRY_MIN_CONFIDENCE` (≈0.85) distinct from `VISION_MIN_CONFIDENCE`. (The
subscriber still holds their card/QR, so a near-miss read just falls back to that.)
- **Opt-in** per camera (`config.anpr`), so a site that didn't ask for plate-entry is unaffected.
- **Debounce is REQUIRED — for correctness, NOT CPU.** The camera re-fires ~1 Hz while a car is
present; emitting a read every second would drive REPEAT entries (a fleet sub opens a 2nd
occurrence; a single-car sub spams "already inside") or, on an exit camera, repeat exits
(`sub.refused.noSession` after the first, and FIFO could phantom-close another occurrence). So the
same plate on the same camera within ~10–15 s = ONE credential presentation.
- NB: a direction-bound camera does NOT flip entry↔exit on repeat reads (the barrier's direction
fixes the verb), so the earlier "flip-flop" fear was wrong — but repeat-same-direction is still
bad. Debounce stands.
- **Threat-model rule preserved by construction.** A plate is trivially spoofable (print it on
paper); it must NEVER be the sole reason a barrier opens. Routing through the existing
SubscriptionFlow means the plate is just another credential through the same gate (active / window
/ blocklist / car-count) — not a bypass. See [[opencv-anpr-service]], [[append-only-event-chain]].
### To validate (booth-PC test day, ~2026-06-23)
The dev-PC ANPR is ~50 ms/frame, but that says little about the hardened **booth appliance** (likely a
low-power CPU, possibly 5–15× slower). Real-hardware latency is the open number. The user is bringing
the actual booth PC to test on.
## Rejected / out of scope (and why)
- **Vision monitors the RTSP livestream continuously** for presence — rejected: rebuilds (worse) what
the camera already does (presence), pegs the appliance CPU 24/7, and the leave-detection heuristic
is no cleaner than a 30 s timeout.
- **Vision polls every ~1 s to track THE car, segment a queue, count cars, read make/model** — a
genuinely harder need (bumper-to-bumper cars the camera can't tell apart). Set ASIDE, not dismissed:
it needs a general vehicle DETECTOR ([[opencv-anpr-service]] is plate-only today) + "same-car-vs-new"
logic + make/model (a third, weak, heavy model), and its viability is **gated on appliance
inference budget we cannot measure on the dev PC**. Revisit only if real traffic + hardware justify
it. The vision feasibility was checked: `fast_alpr` live + ready; plate-only; no vehicle/presence
detector exists yet.
- **Gate the entry ticket button on lane-busy** (don't print when no car) — deferred. The lane-busy
signal supports it, but it gates a PHYSICAL action so it must FAIL-OPEN (allow when presence is
unknown). Parked pending the user's real goal (anti-spam print vs one-ticket-per-car).
+74
View File
@@ -0,0 +1,74 @@
---
type: concept
tags: [parking, data, admin, safety]
sources: []
updated: 2026-06-22
status: settled
---
# Soft Delete & the Recycle Bin
A safety net for accidental admin deletes. Master-data deletes used to be **hard** and
**unrecoverable** — an admin who deleted a user, role, subscription, or plan lost it for good.
Now a delete **soft-deletes** (stamps the row) and the item waits in a **recycle bin** where an
admin can **restore** or **purge** it; unrestored items **auto-purge** after a retention window.
Built 2026-06-22 (migration `0012_soft_delete`).
## What it covers (and what it deliberately doesn't)
Soft-delete is for the **mutable master-data** tables only:
| Resource | Table(s) | Notes |
| --- | --- | --- |
| Users | `users` | A soft-deleted user **cannot log in** (the login route rejects `deleted_at != null`). |
| Roles | `roles` (+ `role_permissions` kept) | Permission rows survive, so a restore brings the role back intact. |
| Subscriptions | `subscriptions` (+ credentials/plates kept) | Distinct from `status: "revoked"` — see below. A soft-deleted sub does **not** open the barrier. |
| Plans | `subscription_plans` | **Versioned**: a soft-delete stamps **every version row** of the `plan_id`; the bin shows/restores it as ONE item. |
| Tariffs | `tariffs` | Has soft-delete for completeness; today the site runs one tariff and there's no delete button — recovery is via the bin. Immutable `tariff_versions` ride along (kept for repricing). |
**Out of scope — the signed ledger.** The append-only, hash-chained `ledger_events` has **no
delete path by design** ([[append-only-event-chain]]); soft-delete is purely for the mutable
master data. A correction to history is still a new *appended* event, never an edit/delete.
## Mechanics
- **Columns:** every covered table gets a nullable `deleted_at` (ISO instant; null = live) and
`deleted_by` (the admin user id). Additive `ALTER ADD COLUMN` — backward-compatible.
- **Delete = stamp.** Each resource's own `DELETE` route now sets the stamps instead of removing
the row. The row vanishes from every catalog because the list/lookup queries filter
`deleted_at IS NULL`.
- **Recycle bin API** (`recyclebin:*` permission): `GET /api/recycle-bin` lists everything
soft-deleted across kinds; `POST /api/recycle-bin/:kind/:id/restore` clears the stamps;
`DELETE /api/recycle-bin/:kind/:id` purges (the real `DELETE`, + children). UI: a **Recycle
bin** tab under Setup. Code: `apps/server/src/recycle-bin.ts` (+ `routes/recycle-bin.ts`),
`apps/web/src/RecycleBin.tsx`.
- **Retention sweep.** A 6-hourly (+ startup) job auto-purges items deleted longer than
`RECYCLE_BIN_RETENTION_DAYS` (default **30**) ago. `0`/negative = keep forever.
## Invariants & edge cases
- **No-lockout still holds.** The "last admin" check counts only **live** admins (a soft-deleted
admin can't log in, so they don't count) — you can't delete yourself into a locked-out box. See
[[local-jwt-auth]].
- **Soft-delete vs. domain lifecycle.** A subscription's `revoke`/`reactivate` and a plan's
`active=0` retire are **domain states** that keep the item *visible* in its catalog (barred /
unsellable). `deleted_at` is different: it removes the item from the catalog entirely,
recoverable only from the bin. Both coexist. See [[subscription]].
- **Unique-name reuse.** `username` / role `name` are `UNIQUE` across **live AND deleted** rows,
so you can't create a new user reusing a deleted user's name until that row is restored or
purged — the create route returns a clear 409 pointing at the recycle bin (rather than a raw
constraint error).
- **Dangling references on restore.** A restored user points at its `roleId`; if that role is
itself deleted, the user reappears with a deleted role. We **don't auto-cascade** (keep it
predictable) — the bin lists both; the admin restores the role too. The role guard resolves a
missing role to an **empty** permission set (safe-by-default), so a dangling role never
escalates.
- **"In use" checks count live only.** A plan blocked from deletion "while referenced" counts
only **live** subscriptions; a soft-deleted subscriber's `planId` reference doesn't block it.
## Permission
`recyclebin:read` (view), `recyclebin:update` (restore), `recyclebin:delete` (purge) — admin-grade
(a restore can revive a privileged user/role; a purge is permanent). Folded into the
code-defined PERMISSIONS grid; the built-in `admin` role holds them. See [[local-jwt-auth]].
+9 -3
View File
@@ -37,9 +37,15 @@ Authentication and authorization, kept **fully local** — a direct consequence
**last user holding admin** — administration can never be locked out of the appliance.
- `event:void` is a permission, NOT a ledger delete: the append-only signed chain is untouched; the
permission only gates who may APPEND a void event (there is no void API route yet — forward seam).
- The grid is **extensible** — adding a feature adds its `resource:action` rows. Latest: **`log:read`**
(a new `log` resource) gates the diagnostic-log viewer (`GET /api/logs`); admin holds it, and it's
grantable to a diagnostic role. See [[app-logs]].
- The grid is **extensible** — adding a feature adds its `resource:action` rows. Recent additions:
**`log:read`** (gates the diagnostic-log viewer, `GET /api/logs`; see [[app-logs]]); **`report:read`**
(the admin Reports dashboard; see [[reporting-analytics]]); and **`recyclebin:read/update/delete`**
(view / restore / purge soft-deleted master data; see [[soft-delete]]). Admin holds them all; each is
grantable to a scoped role.
- **Soft-deleted users can't authenticate.** The login route rejects a user whose `deleted_at` is set
(with the same generic "invalid credentials" so a deleted account isn't enumerable). The no-lockout
"last admin" check counts only LIVE admins, so soft-deleting can't strand administration. See
[[soft-delete]].
- **No privilege escalation through the RBAC system itself.** `role:create`/`role:update` and
`user:create`/`user:update` are themselves grantable, so a non-admin could otherwise self-escalate.
Guards (`routes/roles.ts`, `routes/users.ts`): a caller may only put permissions on a role that
+103
View File
@@ -58,3 +58,106 @@ A **Hikvision** unit ("Camera 20", MAC `94:e1:ac:…`, Hikvision OUI) at `10.0.1
correct JPEG magic. Digest handshake works through `HttpCamera`.
- Reaching it from the WSL dev box required forcing the source address (`config.localAddress`,
threaded into the driver) — see [[wsl-dev-networking]] (multi-subnet source-selection trap).
## Camera PUSH — "Alarm Server" event notifications (2026-06-22)
Separate from the **pull** snapshot path above: newer Hikvision firmware can **push** an event to
us. Under **Event → Smart/VCA** (e.g. line crossing / intrusion / "Vehicle Detection") the unit
exposes **Detection Target: Human / Vehicle** — selecting **Vehicle** + **Notify Surveillance
Center**, then **Alarm Settings → Alarm Server**, makes the camera **HTTP-POST an
`EventNotificationAlert`** to a URL we host on each detection. Same machine-call shape as the
[[dingtian-relay]] Input Link push — no polling.
- **Ingress:** `POST /api/devices/hikvision/:deviceId/event` (`apps/server/src/routes/hikvision-alarm.ts`).
**Source-IP guarded** (must come from the device's configured `host`) + **optional HTTP Digest**
(some firmware can't authenticate the Alarm Server call → source-IP only). NOT behind the SPA
cookie/CSRF (it's a device call), exactly like the Dingtian push.
- **Config:** added to the `hikvision` driver — `alarmPushEnabled` (bool), `pushUser`/`pushPassword`
(optional Digest). The driver is now `pushesToBackend: true`, so first-run setup offers the backend
push IP. Point the camera's Alarm Server at `http://<backend-ip>:<port>/api/devices/hikvision/<deviceId>/event`.
- **Discovery-first:** the endpoint is **permissive** — accepts ANY content-type as raw bytes (event
XML, multipart-with-JPEG, or JSON; Hik's format varies by model/firmware), records the **verbatim
body** as a `kind:"alarm"` device_event, and best-effort extracts `eventType` / `target` / `plate`
/ `dateTime` / `channelID`. The point of this first cut is to **see exactly what a given camera
sends** (inspect via `GET /api/events` or the server log) before wiring it to the read bus.
- **Not yet a barrier trigger.** It records + breadcrumbs only; it does NOT emit a `DeviceReadEvent`
or open anything. A plate read is **advisory, never the sole reason** a barrier opens
([[append-only-event-chain]], [[opencv-anpr-service]]). Two consumers were since designed off this
same vehicle event — see **[[lane-presence-and-anpr-entry]]**: (a) BUILT — advisory lane busy/free
booth lights; (b) BUILT (2026-06-22) — the ANPR "bridge" (`anpr-entry.ts`) that snapshots → ANPR →
emits a `kind:"plate"` read for a SUBSCRIBER match through the existing gated flow (a small
`apps/server` handler class, not a service). If the camera ever emits its own `<plateNumber>` we'd
use it directly; this `DS-2CD1043G2`
does not, so the server pulls the frame and hands it to the [[opencv-anpr-service|vision service]].
### Gotchas learned the hard way (2026-06-22 field session)
Several traps surfaced trying to get a real camera to push. In order of how long each cost:
- **WSL rewrites the inbound source IP.** On the dev host (WSL mirrored mode), an inbound LAN packet
arrives at our server with its **source rewritten to the host's own IP** (`10.0.10.203`), not the
camera's. The source-IP guard then rejects every push as a mismatch. Fix: a per-device
**`skipSourceIpCheck`** config flag (a Setup checkbox) that bypasses the IP guard — the signed
ledger + optional Digest remain the real guards. Leave OFF on a normal LAN.
- **The setup checkbox saved booleans as the STRING `"true"`.** The generic config-field form had no
boolean renderer, so a `type:"boolean"` field fell through to a text input. Fixed (checkbox
renderer); the server also coerces `"true"`/`1`/`yes`/`on` defensively.
- **The camera's "Test" button proves almost nothing.** It does a TCP/connectivity probe and reports
"service available" on ANY HTTP reply (even our 404) — it does **not** POST a real event to your
URL. Only a real detection (or the ISAPI `httpHosts/<id>/test`) actually exercises the path.
- **`httpBroken` latches.** Once the camera marks the host broken (from earlier failed deliveries),
it stays `true` across reboots and won't retry. Clear it by **re-PUTting** the httpHost config
(`PUT /ISAPI/Event/notification/httpHosts/1` with `<httpBroken>false</httpBroken>`).
- **"Notify Surveillance Center" ≠ the HTTP Alarm Server** on some firmware (separate upload
channels). Always confirm the **Arming Schedule** covers the test time, too (a silent killer).
- **⭐ THE ROOT CAUSE (2026-06-22): no detection AREA drawn.** This is what actually defeated us for
most of a day. On the motion/smart-detection page there's a **Draw Area** step — if **no region is
drawn on the frame, the camera detects nothing, generates NO event, and therefore posts nothing**
anywhere (httpHost, FTP, alarm stream all stay silent because there's no event upstream). Enabling
the detection + ticking Notify Surveillance Center is **not enough** — you must draw the region.
Once an area was drawn, the very first vehicle produced a clean POST. **Check this FIRST.**
### Confirmed real payload (DS-2CD1043G2-LIU, V5.8.10, 2026-06-22)
What this camera actually POSTs on a motion event with a target — captured end-to-end:
- **`Content-Type: multipart/form-data; boundary=boundary`**, one XML part named `MoveDetection.xml`
(`Content-Type: application/xml`). A real frame/JPEG *may* be attached as a second part on other
event types — our endpoint stores the readable head; splitting an image part to `snapshots` is a
forward step (not needed for plain motion).
- The XML is an `EventNotificationAlert` with the fields we care about:
- `<eventType>VMD</eventType>` (Video Motion Detection) + `<eventState>active</eventState>`
- **`<targetType>vehicle</targetType>`** — the camera classifies **vehicle vs human ON-DEVICE**.
(Field is `targetType`, NOT `detectionTarget`.) This means simple presence + class comes for
free, no vision model needed for that part.
- `<targetInfo><targetRect>` with normalized `X/Y/width/height` (0–1) — the **bounding box**.
- `<channelID>`, `<macAddress>` (provenance), `<dateTime>` — **but the dateTime is GARBAGE**
(`2032-…`) because this unit's **RTC is dead** (see below); we use our own server receive time,
never the camera's. (No `<plateNumber>` — this is a motion event, not an ANPR camera.)
### If a camera still won't push — diagnostics (read its OWN state)
Only after confirming the **detection area is drawn** + arming schedule covers now + Notify
Surveillance Center is on. These read the camera directly (no cooperation from our server):
1. **`netstat` on the camera (via SSH) while you trigger** — watch for an OUTBOUND line
`cam:port → server:3000`. It appearing = the camera fired and is delivering (then check our
`/api/devices/hikvision/alarms`). None = no event was generated (almost always: **no area drawn**).
2. **`GET /ISAPI/Event/notification/alertStream`** (Digest, needs a clean handshake) — the live event
bus. NB: a `curl --digest` tap that fails the handshake returns empty and looks like "no events"
— don't over-read silence here (this misled us); the netstat watch above is more reliable.
3. **SSH `showStatus` / `dmesg`** expose internal state. ⚠ **Caveat learned the hard way:** these
surface scary-looking strings that are **red herrings** — `EventScribe: except`, a `diskfull`
error on `Event/triggers` (on a camera with **no disk**), and `fh rtc get time error` / a 1970
clock. On our unit ALL of these were present **and the camera worked fine** once an area was
drawn. The dead RTC is real (hence the bogus `dateTime`) but **harmless** to event push. **Do NOT
conclude "dead camera / RMA" from these** — they are not proof of a broken event engine.
> **Correction (2026-06-22):** an earlier version of this page concluded this DS-2CD1043G2-LIU was a
> **defective unit needing RMA**, based on the silent alertStream + `diskfull`/`EventScribe:except` +
> dead RTC surviving a full factory reset. **That was WRONG.** The camera was healthy; the real cause
> was simply **no detection area drawn**, so no event was ever generated. The `diskfull`/RTC findings
> were unrelated quirks (RTC genuinely dead, but it doesn't block event push). Lesson: don't
> escalate to "hardware fault" while a basic config precondition (the drawn region) is unmet — and
> treat vendor status-API error strings as unreliable. The pull + [[opencv-anpr-service|vision]] path
> remains a valid fallback, but it was not needed here.
+7 -3
View File
@@ -258,9 +258,13 @@ LPR/ANPR plate identity** (the plate binding below):
number. The GEE readers are combo QR + RFID (ID/IC/NFC), so the same device captures both. A
Wiegand-out reader keeps a future autonomous path open ([[entry-exit-readers]]); the
[[dingtian-relay]] has no onboard card list.
- **Plate (LPR/ANPR) — NOT YET IMPLEMENTED.** When plate-bound (below), a matching plate read is an
accepted identity too. The vision/ANPR service that produces plate reads is future work
([[opencv-anpr-service]] / [[lpr-camera]]); until it exists, plate binding has no live source.
- **Plate (LPR/ANPR) — matching is BUILT, the live SOURCE is the one missing wire.** When plate-bound
(below), a matching plate read is an accepted identity — and `subscription-flow.ts` `match()` +
`read-dispatch.ts` already handle a `via:"plate"` read end to end (gate + entry/exit). What's
missing is the thing that EMITS a plate read from the lane camera: the **ANPR "bridge"** (a small
handler in `apps/server`, not a new service) that snapshots on a camera vehicle event, runs
[[opencv-anpr-service|ANPR]], and on a high-confidence match emits the plate onto the read bus. PLANNED,
scoped to subscribers only. See [[lane-presence-and-anpr-entry]] for the full design + decisions.
Both feed the host as a reader event whose `source` is `wiegand` / `qr` (the `IdentitySource`
already in the model) and whose value is the credential id.
+2
View File
@@ -96,8 +96,10 @@ Counts: 4 sources · 19 entities · 45 concepts · 7 decision records.
- [[anti-passback]] — block/flag one id entering twice without an exit; fold over open sessions.
- [[device-events]] — unsigned hardware telemetry (relay/printer/camera/reader/input); separate from the signed ledger.
- [[app-logs]] — the third stream: diagnostic logs (backend warn+ pino sink + frontend errors) → app_logs; log:read viewer; pruned by age+row cap.
- [[soft-delete]] — BUILT: accidental admin deletes of master data (users/roles/subs/plans/tariffs) are soft (deleted_at) + recoverable from a recycle bin; auto-purge after N days; signed ledger out of scope.
- [[subscription]] — recurring plan (e.g. 10,000 ALL/month); RF/QR or plate identity, car-count + max-concurrent, host-in-loop; short-circuits payment. (Renamed from "permit"; time-of-day windows noted, deferred.)
- [[opencv-anpr-service]] — host-side vision microservice: ANPR (plate identity) + vehicle verification (anti-plate-spoofing witness); fast-alpr (MIT, YOLOv9+CCT/ONNX) the evaluated recognizer baseline.
- [[lane-presence-and-anpr-entry]] — camera vehicle detection → (BUILT) advisory lane busy/free booth lights + (BUILT) the ANPR "bridge" (`anpr-entry.ts`): a subscriber's plate read at the lane admits them via the existing gated subscription flow (match-before-emit; subscriber-only). Measured camera limits; rejected the queue-tracking/livestream ideas.
- [[blocklist]] — barred plates/cards refused at entry (never at exit); signed, attributed.
## Concepts — frontend / operator UI
+92
View File
@@ -1354,3 +1354,95 @@ so the booth bundle is untouched. reports.test.ts (10) pins the sums/tz/split/du
90/90, build+lint 14/14. Also (earlier same session): a camera "Test ANPR" probe in first-run setup
(`POST /api/setup/test-anpr`) — snapshot→vision analyze, fail-soft, shown only when a camera's ANPR
opt-in is checked. See [[reporting-analytics]], [[opencv-anpr-service]].
## [2026-06-22] feat | Soft delete + recycle bin for master data (migration 0012)
Accidental admin deletes used to be hard + unrecoverable. Now users/roles/subscriptions/plans/
tariffs soft-delete: migration 0012 adds nullable deleted_at + deleted_by; each resource's DELETE
route STAMPS instead of removing, and every catalog list filters deleted_at IS NULL. A recycle bin
(GET /api/recycle-bin, POST .../restore, DELETE .../:id purge — gated recyclebin:read/update/delete,
new resource in the PERMISSIONS grid) lists everything soft-deleted, restores, or purges; a 6-hourly
+ startup sweep auto-purges items older than RECYCLE_BIN_RETENTION_DAYS (default 30; 0 = forever).
Key invariants: soft-deleted users CAN'T log in (login rejects deleted_at; no-lockout counts live
admins only); a soft-deleted subscription doesn't open the barrier; PLANS are versioned so a delete
stamps all version rows of the plan_id (bin shows one item); username/role-name UNIQUE spans deleted
rows so reuse returns a clear 409 pointing at the bin; restore doesn't auto-cascade a dangling role
(guard resolves missing role → empty perms, safe). Signed ledger is OUT of scope (no delete path).
Web: a Recycle bin tab under Setup (RecycleBin.tsx). Tests: recycle-bin.test.ts (9 unit) +
recycle-bin-routes.test.ts (4 integration: delete→can't-login→restore→login, purge, gating, 409
reuse); server 103/103, build+lint 19/19, i18n parity (sq+en). See [[soft-delete]], [[local-jwt-auth]].
## [2026-06-22] feat | Hikvision Alarm Server event-push ingress (discovery-first)
Newer Hik firmware (Event → Smart/VCA "Detection Target: Human/Vehicle" + Notify Surveillance
Center + Alarm Settings → Alarm Server) HTTP-POSTs an EventNotificationAlert on each detection.
Added POST /api/devices/hikvision/:deviceId/event (routes/hikvision-alarm.ts) — same machine-push
pattern as the Dingtian Input Link: source-IP guarded + OPTIONAL Digest, not behind SPA cookie/CSRF.
Permissive/discovery-first: a wildcard content-type parser takes ANY body as raw bytes (XML,
multipart+JPEG, JSON — Hik varies by firmware), stores it verbatim as a kind:"alarm" device_event,
and best-effort extracts eventType/target/plate/dateTime/channelID for the summary + log line. The
hikvision DRIVER gained alarmPushEnabled + pushUser/pushPassword config and pushesToBackend:true (so
setup offers the backend push IP). NOT yet a barrier trigger or DeviceReadEvent — records only; the
read-bus/ANPR wiring is the next step once the real payload is captured (advisory-only rule still
governs). Tests: hikvision-alarm.test.ts (6: vehicle XML summary, ANPR plate, raw JSON, wrong-IP
404, disabled 404, unknown-device 404); server 109/109, build+lint 14/14. See [[lpr-camera]].
## [2026-06-22] debug | Hikvision event-push field session — fixes + a verified-dead camera
Long session getting a real Hik camera to POST events. Server/integration fixes (committed): listen
on ALL methods (camera probes with GET/etc, not just POST); record rejected pushes too (kind
"alarm-rejected" + reason) so "nothing arrived" is never ambiguous; a GET /api/devices/hikvision/
alarms read endpoint; per-device skipSourceIpCheck (WSL mirrored mode REWRITES the inbound source IP
to the host's own, so the source-IP guard rejected every push); and a real checkbox renderer for
type:"boolean" config fields (they were saving the STRING "true"). Then proved — via the camera's
OWN state, not our server — that the specific DS-2CD1043G2-LIU unit has a DEAD event engine: silent
alertStream (no heartbeat), EventScribe:except, diskfull on Event/triggers, dead RTC (rtc get time
error / clock at 1970), and ZERO outbound to :3000 over a 3-min netstat watch, surviving reboot +
basic reset + FULL factory reset. Verdict: defective camera (RMA), not our code. Captured the
diagnostic method (alertStream silence / SSH showStatus / netstat) in [[lpr-camera]]. Fallback for a
dead-push camera: pull + [[opencv-anpr-service|vision]] (the same camera still serves snapshots).
## [2026-06-22] CORRECTION | Hik camera was NOT defective — the cause was an undrawn detection area
Supersedes the earlier "[2026-06-22] debug" entry's conclusion that the DS-2CD1043G2-LIU had a dead
event engine needing RMA. WRONG. The camera is healthy; it pushed a clean event the instant a
detection AREA was drawn on the frame (the "Draw Area" step). With no region drawn, the camera
detects nothing → generates no event → posts nothing anywhere — which produced all the symptoms
(silent alertStream, zero outbound to :3000). The diskfull / EventScribe:except / dead-RTC findings
were red herrings (the RTC is genuinely dead, hence a bogus 2032 dateTime in the payload, but it does
NOT block event push). Lesson: don't escalate to "hardware fault" while a basic config precondition
is unmet; vendor status-API error strings are unreliable. Confirmed real payload: multipart/form-data
(MoveDetection.xml) with EventNotificationAlert -> eventType=VMD, eventState=active,
targetType=vehicle (vehicle/human classified ON-DEVICE), targetRect bounding box. The push endpoint +
all-methods + skipSourceIpCheck + rejection-recording are all validated against the real device now.
See [[lpr-camera]] (corrected).
## [2026-06-22] design+build | Lane presence (BUILT) + ANPR subscriber-entry "bridge" (PLANNED)
Off the now-working Hik vehicle event: BUILT advisory lane busy/free booth barrier lights
(LaneStatus + WS; timeout-driven "free" since the camera sends no leave signal — TTL settled at 30s
after a controlled in/out test showed movement-driven ~1-3s re-fire but ~15-25s gaps for a still
car, and ~no dwell lag on leave). Measured the camera's hard limits: no current-state poll exists,
and flipping notificationRecurrence beginning->recurring via ISAPI is silently reverted (firmware
locked). Then narrowed the bigger ambition to a clean, high-value scope: ANPR for SUBSCRIBERS ONLY —
a plate read at the lane admits a subscriber through the EXISTING gated subscription flow. Found the
whole subscription side already supports via:"plate" (match + dispatch + gate); the one missing piece
is a small `apps/server` HANDLER ("the bridge", ~40 lines, NOT a new service/container) that on a
camera vehicle event snapshots -> ANPR -> on a HIGH-confidence match (new VISION_ENTRY_MIN_CONFIDENCE)
-> debounces (required for ledger correctness, not CPU: ~1Hz re-fire would drive repeat entries) ->
emitRead{kind:"plate"}. Both directions, opt-in per camera (config.anpr), plate never the sole
authority (routes through the gate). REJECTED: continuous livestream presence + per-car queue
tracking/make-model (needs a vehicle detector the plate-only vision lacks + appliance compute we can't
measure on the dev PC). Vision checked: fast_alpr live, ~50ms/frame on DEV PC (appliance TBD —
booth-PC test ~2026-06-23). New page [[lane-presence-and-anpr-entry]]; updated [[lpr-camera]],
[[subscription]], index.
## [2026-06-22] build | ANPR subscriber-entry "bridge" — BUILT
Built the bridge planned in the previous entry: `apps/server/src/anpr-entry.ts` (`AnprBridge`). On a
vehicle/non-`inactive` push from an `anpr`-opted-in camera, `hikvision-alarm.ts` hands the deviceId
to the bridge (fire-and-forget, never awaited on the camera's 200). The bridge debounces
(camera-level, pre-snapshot), pulls a FRESH snapshot (reused `snapshot.ts buildCamera`), runs
`vision.analyze`, applies a stricter entry floor (`VISION_ENTRY_MIN_CONFIDENCE`=0.85), then — the key
safety choice settled with the user — MATCHES the plate to a subscription BEFORE emitting: a
subscriber → `emitRead{kind:"plate"}` (→ existing `ReadDispatcher`→gated `SubscriptionFlow`); a
non-subscriber → advisory `anpr-skip` device_event, nothing emitted (so a random/printed plate never
reaches the transient plate-as-ticket exit path). Fail-soft throughout. `server.ts` reordered so the
read flows are constructed before the hik-alarm registration. New env: `VISION_ENTRY_MIN_CONFIDENCE`,
`ANPR_DEBOUNCE_MS`. Tests: `anpr-entry.test.ts` (7) + `hikvision-alarm.test.ts` wiring (3); full
server suite 130 green, monorepo build+lint green. Flipped [[lane-presence-and-anpr-entry]] §2 +
table row PLANNED->BUILT; updated [[lpr-camera]]. STILL OPEN: booth-PC ANPR latency (~2026-06-23).