diff --git a/apps/server/.env.example b/apps/server/.env.example index ee1212a..c0c724d 100644 --- a/apps/server/.env.example +++ b/apps/server/.env.example @@ -87,3 +87,15 @@ WS_ALLOWED_ORIGINS=http://localhost:5173,tauri://localhost,http://tauri.localhos # is never entitled to a module its Komodo stack env does not name. Required modules # (parking) are always on. See wiki/decisions/venue-modules.md. #MODULES_ENTITLED=parking,validation + +# Car Wash review outbox (wiki/concepts/vision-review-outbox.md) ------------------------- +# The operator's category choice is a hypothesis: each wash order with a vehicle read queues +# the vehicle CROP (plate blurred) + the choice for a trusted remote reviewer, drained one-way +# over the private overlay (Netbird). All three or off. URL = the collector's ingest endpoint +# (reachable only over the overlay); TOKEN = this booth's own bearer token; BOOTH_ID = a +# pseudonymous label the reviewer maps to a site (NEVER the site name — it travels with every +# item). Set in the Komodo stack env, per booth. Nothing is queued while off. +# CARWASH_REVIEW_URL= +# CARWASH_REVIEW_TOKEN= +# CARWASH_REVIEW_BOOTH_ID= +# CARWASH_REVIEW_INTERVAL_SEC=60 diff --git a/apps/server/src/modules/carwash/carwash.test.ts b/apps/server/src/modules/carwash/carwash.test.ts index ca9cd87..c38a0bc 100644 --- a/apps/server/src/modules/carwash/carwash.test.ts +++ b/apps/server/src/modules/carwash/carwash.test.ts @@ -597,7 +597,7 @@ describe("vision category — advisory, flagged, never authoritative", () => { await openSession("T-V1"); seeVehicle("T-V1", "suv", 0.91); const look = (await app.inject({ method: "GET", url: "/api/carwash/session/T-V1", headers: { cookie: a.cookie } })).json(); - expect(look.vision).toEqual({ bodyType: "suv", confidence: 0.91, snapshotId: "snap-1" }); + expect(look.vision).toMatchObject({ bodyType: "suv", confidence: 0.91, snapshotId: "snap-1" }); expect(look.suggestedCategoryId).toBe(ids.suv); // Unmapped class → shown, nothing suggested. await openSession("T-V2"); diff --git a/apps/server/src/modules/carwash/index.ts b/apps/server/src/modules/carwash/index.ts index 12bc8b9..f266f31 100644 --- a/apps/server/src/modules/carwash/index.ts +++ b/apps/server/src/modules/carwash/index.ts @@ -1,4 +1,5 @@ import type { ServerModule } from "../index.js"; +import { ReviewOutbox, reviewUploadConfigFromEnv } from "./review-outbox.js"; import { carwashRoutes } from "./routes.js"; import { CarwashService } from "./service.js"; @@ -10,10 +11,18 @@ import { CarwashService } from "./service.js"; export const carwashModule: ServerModule = { id: "carwash", async register(app, deps) { - const service = new CarwashService(deps, app.log); + // The review outbox (wiki/concepts/vision-review-outbox.md): on when the stack env + // names a collector URL, a per-booth token and a pseudonymous booth id; off = no + // queueing at all. One-way, background, never on the intake path. + const cfg = reviewUploadConfigFromEnv(); + const outbox = new ReviewOutbox(deps.db, app.log, cfg); + app.log.info(cfg ? `carwash review upload: on → ${new URL(cfg.url).host} as ${cfg.boothId}` : "carwash review upload: off"); + outbox.start(); + app.addHook("onClose", async () => outbox.stop()); + const service = new CarwashService(deps, app.log, outbox); // A wash ordered with payAt = "booth" is a charge line on the parking settlement; // the core calls back after the payment is signed so the order is marked paid. deps.payStation.registerChargeProvider(service.chargeProvider()); - await carwashRoutes(app, deps, service); + await carwashRoutes(app, deps, service, outbox); }, }; diff --git a/apps/server/src/modules/carwash/review-outbox.test.ts b/apps/server/src/modules/carwash/review-outbox.test.ts new file mode 100644 index 0000000..ff4d649 --- /dev/null +++ b/apps/server/src/modules/carwash/review-outbox.test.ts @@ -0,0 +1,219 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import sharp from "sharp"; +import { createTestDb } from "@parking/db/testing"; +import { carwashOrders, carwashReviewOutbox, deviceEvents, snapshots, type Db } from "@parking/db"; +import type { FastifyInstance } from "fastify"; +import { buildServer } from "../../server.js"; +import { login, makeLog, minutesAgo, seedTariff, seedUser, silentLogger } from "../../test-helpers.js"; +import { EXPIRE_DAYS, ReviewOutbox, makeReviewCrop, operatorRef, reviewUploadConfigFromEnv } from "./review-outbox.js"; + +// The review outbox, booth side (wiki/concepts/vision-review-outbox.md): a plate-blurred +// vehicle crop + the operator's choice, queued off the intake path, drained one-way with +// backoff, never blocking the wash, never naming the site. + +/** A 400×300 frame: grey ground, a red "car" block, a white "plate" strip inside it. */ +async function frame(): Promise { + return sharp({ create: { width: 400, height: 300, channels: 3, background: { r: 90, g: 90, b: 90 } } }) + .composite([ + { input: { create: { width: 200, height: 120, channels: 3, background: { r: 200, g: 30, b: 30 } } }, left: 100, top: 100 }, + { input: { create: { width: 60, height: 16, channels: 3, background: { r: 255, g: 255, b: 255 } } }, left: 170, top: 190 }, + ]) + .jpeg() + .toBuffer(); +} +const CAR = { x1: 100 / 400, y1: 100 / 300, x2: 300 / 400, y2: 220 / 300 }; +const PLATE = { x1: 170 / 400, y1: 190 / 300, x2: 230 / 400, y2: 206 / 300 }; + +/** Mean GREEN over a region — the white plate reads 255, the red car around it 30, so a + * blurred plate drops far below 255 as the red bleeds in. */ +async function meanGreen(buf: Buffer, region: { left: number; top: number; width: number; height: number }): Promise { + const { data, info } = await sharp(buf).extract(region).raw().toBuffer({ resolveWithObject: true }); + let sum = 0; + for (let i = 1; i < data.length; i += info.channels) sum += data[i]!; + return sum / (data.length / info.channels); +} + +describe("makeReviewCrop", () => { + it("cuts the vehicle (with margin), blurs the plate inside it, caps the edge", async () => { + const shot = await frame(); + const crop = await makeReviewCrop(shot, CAR, PLATE); + expect(crop.plateBlurred).toBe(true); + // Box 200×120 + 8 % margin each side ≈ 232×139; no upscaling. + expect(crop.width).toBeGreaterThanOrEqual(228); + expect(crop.width).toBeLessThanOrEqual(236); + expect(crop.height).toBeGreaterThanOrEqual(135); + // The white plate is gone: over the plate strip (crop coords: the frame's 170..230 × + // 190..206 shifted by the crop origin 84,90) the same region cut straight from the + // frame is white, the review crop is the red bleeding in. + const plain = await sharp(shot).extract({ left: 84, top: 90, width: crop.width, height: crop.height }).jpeg().toBuffer(); + const strip = { left: 170 - 84, top: 190 - 90, width: 60, height: 16 }; + expect(await meanGreen(plain, strip)).toBeGreaterThan(240); + expect(await meanGreen(crop.bytes, strip)).toBeLessThan(180); + // Without a plate box: same crop, nothing blurred. + const noPlate = await makeReviewCrop(shot, CAR, null); + expect(noPlate.plateBlurred).toBe(false); + // A big frame is capped to the max edge. + const big = await sharp({ create: { width: 2560, height: 1440, channels: 3, background: "#444" } }).jpeg().toBuffer(); + const capped = await makeReviewCrop(big, { x1: 0, y1: 0, x2: 1, y2: 1 }, null); + expect(Math.max(capped.width, capped.height)).toBe(640); + }); +}); + +describe("config + pseudonyms", () => { + it("needs url, token and booth id together; the operator ref is a keyed hash", () => { + expect(reviewUploadConfigFromEnv({})).toBeNull(); + expect(reviewUploadConfigFromEnv({ CARWASH_REVIEW_URL: "https://c/ingest", CARWASH_REVIEW_TOKEN: "t" })).toBeNull(); + const cfg = reviewUploadConfigFromEnv({ CARWASH_REVIEW_URL: "https://c/ingest", CARWASH_REVIEW_TOKEN: "t", CARWASH_REVIEW_BOOTH_ID: "b7", CARWASH_REVIEW_INTERVAL_SEC: "5" }); + expect(cfg).toMatchObject({ boothId: "b7", intervalSec: 60 }); // below the 10 s floor → default + expect(operatorRef("b7", "lavazhier")).toHaveLength(16); + expect(operatorRef("b7", "lavazhier")).not.toBe(operatorRef("b8", "lavazhier")); + expect(operatorRef("b7", "lavazhier")).not.toContain("lavazhier"); + }); +}); + +describe("queue + drain", () => { + let db: Db; + let close: () => void; + beforeEach(() => { + const t = createTestDb(); + db = t.db; + close = t.close; + }); + afterEach(() => close()); + + const cfg = { url: "https://collector.overlay/ingest", token: "secret-1", boothId: "booth-7", intervalSec: 60 }; + const read = { bodyType: "car" as const, confidence: 0.86, snapshotId: "snap-1", box: CAR, plateBox: PLATE }; + const item = { orderId: "o-1", createdAt: "2026-09-06T10:00:00.000Z", createdBy: "lavazhier", categoryId: "car", categoryName: "Vetura", serviceName: "Standard", visionCategoryId: "car", downgraded: false }; + + async function seed(): Promise { + db.insert(snapshots).values({ id: "snap-1", direction: "entry", identity: "T-1", contentType: "image/jpeg", bytes: await frame(), capturedAt: new Date().toISOString() }).run(); + db.insert(carwashOrders).values({ + id: "o-1", identity: "T-1", plate: null, categoryId: "car", categoryName: "Vetura", serviceId: "std", serviceName: "Standard", + priceMinor: 100, currency: "ALL", payAt: "booth", status: "open", createdAt: item.createdAt, createdBy: "lavazhier", + }).run(); + } + + it("enqueues a crop + a payload with no site name, no plate, no operator name; drains with a multipart POST; drops the image once sent", async () => { + await seed(); + const calls: { url: string; init: RequestInit }[] = []; + const fetchFn = vi.fn(async (url: string, init: RequestInit) => { + calls.push({ url, init }); + return new Response("ok", { status: 200 }); + }); + const ob = new ReviewOutbox(db, silentLogger(), cfg, fetchFn); + expect(await ob.enqueue(item, read)).toBe(true); + const row = db.select().from(carwashReviewOutbox).all()[0]!; + expect(row.status).toBe("queued"); + expect(row.image!.length).toBeGreaterThan(500); + expect(row.payload).toMatchObject({ v: 1, booth: "booth-7", order: "o-1", operatorCategory: { id: "car", name: "Vetura" }, vision: { class: "car", confidence: 0.86 }, downgraded: false, image: { plateBlurred: true } }); + expect(JSON.stringify(row.payload)).not.toContain("lavazhier"); + + expect(await ob.drain()).toEqual({ sent: 1, failed: 0, deferred: 0 }); + expect(calls).toHaveLength(1); + expect(calls[0]!.url).toBe(cfg.url); + expect((calls[0]!.init.headers as Record).authorization).toBe("Bearer secret-1"); + const form = calls[0]!.init.body as FormData; + expect(JSON.parse(form.get("meta") as string).item).toBe(row.id); + expect((form.get("image") as File).type).toBe("image/jpeg"); + const after = db.select().from(carwashReviewOutbox).all()[0]!; + expect(after.status).toBe("sent"); + expect(after.image).toBeNull(); + expect(after.sentAt).toBeTruthy(); + expect(ob.status()).toMatchObject({ enabled: true, boothId: "booth-7", queued: 0, sent: 1, failed: 0 }); + }); + + it("defers with backoff on collector/network trouble, abandons on a rejection, a void or expiry, skips without a box", async () => { + await seed(); + let status = 503; + const fetchFn = vi.fn(async () => (status === 0 ? Promise.reject(new Error("ECONNREFUSED")) : new Response("", { status }))); + const ob = new ReviewOutbox(db, silentLogger(), cfg, fetchFn); + await ob.enqueue(item, read); + expect(await ob.drain()).toEqual({ sent: 0, failed: 0, deferred: 1 }); + let row = db.select().from(carwashReviewOutbox).all()[0]!; + expect(row).toMatchObject({ status: "queued", attempts: 1, lastError: "HTTP 503" }); + expect(Date.parse(row.nextAttemptAt!)).toBeGreaterThan(Date.now() + 60_000); + // Not due yet → untouched. + expect(await ob.drain()).toEqual({ sent: 0, failed: 0, deferred: 0 }); + // Due again: a network error defers too; a 422 abandons. + db.update(carwashReviewOutbox).set({ nextAttemptAt: null }).run(); + status = 0; + expect(await ob.drain()).toEqual({ sent: 0, failed: 0, deferred: 1 }); + db.update(carwashReviewOutbox).set({ nextAttemptAt: null }).run(); + status = 422; + expect(await ob.drain()).toEqual({ sent: 0, failed: 1, deferred: 0 }); + row = db.select().from(carwashReviewOutbox).all()[0]!; + expect(row).toMatchObject({ status: "failed", lastError: "rejected: HTTP 422" }); + expect(row.image).toBeNull(); + + // A voided order is not a sample. + status = 200; + await ob.enqueue({ ...item, orderId: "o-1" }, read); + db.update(carwashOrders).set({ status: "void" }).run(); + expect(await ob.drain()).toEqual({ sent: 0, failed: 1, deferred: 0 }); + // Expired items are abandoned without a request. + await ob.enqueue(item, read); + db.update(carwashReviewOutbox).set({ createdAt: new Date(Date.now() - (EXPIRE_DAYS + 1) * 86_400_000).toISOString() }).where(eq(carwashReviewOutbox.status, "queued")).run(); + db.update(carwashOrders).set({ status: "open" }).run(); + const before = fetchFn.mock.calls.length; + expect(await ob.drain()).toEqual({ sent: 0, failed: 1, deferred: 0 }); + expect(fetchFn.mock.calls.length).toBe(before); + expect(ob.status().failed).toBe(3); + + // No vehicle box, no snapshot, or upload off → nothing queued. + expect(await ob.enqueue(item, { ...read, box: null })).toBe(false); + expect(await ob.enqueue(item, { ...read, snapshotId: "gone" })).toBe(false); + expect(await new ReviewOutbox(db, silentLogger(), null, fetchFn).enqueue(item, read)).toBe(false); + }); +}); + +import { eq } from "@parking/db"; + +describe("through the app", () => { + let db: Db; + let close: () => void; + let app: FastifyInstance; + const saved = { ...process.env }; + beforeEach(async () => { + delete process.env.MODULES_ENTITLED; + process.env.CARWASH_REVIEW_URL = "https://collector.overlay/ingest"; + process.env.CARWASH_REVIEW_TOKEN = "tok"; + process.env.CARWASH_REVIEW_BOOTH_ID = "booth-9"; + const t = createTestDb(); + db = t.db; + close = t.close; + app = await buildServer({ db }); + await app.ready(); + }); + afterEach(async () => { + await app.close(); + close(); + for (const k of ["CARWASH_REVIEW_URL", "CARWASH_REVIEW_TOKEN", "CARWASH_REVIEW_BOOTH_ID"]) { + if (saved[k] === undefined) delete process.env[k]; + else process.env[k] = saved[k]; + } + }); + + it("a wash intake with a vehicle read queues a review item; the status route reports it", async () => { + const { username, password } = await seedUser(db, { username: "boss", roleId: "admin" }); + const a = await login(app, username, password); + const hdrs = { cookie: a.cookie, "x-csrf-token": a.csrf }; + seedTariff(db); + const s = (await app.inject({ method: "PUT", url: "/api/carwash/settings", headers: hdrs, payload: { categories: [{ name: "Vetura", visionClasses: ["car"] }], services: [{ name: "Standard" }], prices: [] } })).json(); + const cat = s.categories[0].id, svc = s.services[0].id; + await app.inject({ method: "PUT", url: "/api/carwash/settings", headers: hdrs, payload: { prices: [{ categoryId: cat, serviceId: svc, priceMinor: 500 }] } }); + await makeLog(db).append({ type: "vehicle_entry", source: "manual", identity: "T-R", occurredAt: minutesAgo(30), payload: { sessionRef: "T-R", category: "default" } }); + db.insert(snapshots).values({ id: "snap-r", direction: "entry", identity: "T-R", contentType: "image/jpeg", bytes: await frame(), capturedAt: new Date().toISOString() }).run(); + db.insert(deviceEvents).values({ + id: "read-r", deviceId: "cam-1", category: "camera", kind: "read", + detail: { identity: "T-R", direction: "entry", bodyType: "car", bodyConfidence: 0.9, snapshotId: "snap-r", vehicleBox: CAR, plateBox: PLATE }, + occurredAt: new Date().toISOString(), + }).run(); + + const order = await app.inject({ method: "POST", url: "/api/carwash/orders", headers: hdrs, payload: { identity: "T-R", categoryId: cat, serviceId: svc } }); + expect(order.statusCode).toBe(201); + // Enqueue is fire-and-forget: give the crop a moment. + await vi.waitFor(() => expect(db.select().from(carwashReviewOutbox).all()).toHaveLength(1)); + const status = (await app.inject({ method: "GET", url: "/api/carwash/review/status", headers: { cookie: a.cookie } })).json(); + expect(status).toMatchObject({ enabled: true, boothId: "booth-9", queued: 1, sent: 0 }); + }); +}); diff --git a/apps/server/src/modules/carwash/review-outbox.ts b/apps/server/src/modules/carwash/review-outbox.ts new file mode 100644 index 0000000..510031a --- /dev/null +++ b/apps/server/src/modules/carwash/review-outbox.ts @@ -0,0 +1,327 @@ +import { createHash, randomUUID } from "node:crypto"; +import sharp from "sharp"; +import { and, asc, carwashOrders, carwashReviewOutbox, eq, isNull, lte, or, snapshots, sql, type Db } from "@parking/db"; +import type { NormBox, VehicleRead } from "@parking/shared"; +import type { FastifyBaseLogger } from "fastify"; + +// The Car Wash REVIEW OUTBOX — booth side (wiki/concepts/vision-review-outbox.md). +// +// The operator's category choice at intake is a HYPOTHESIS, not truth (the threat model: +// the operator may err or cheat). So every wash order that has a vehicle read queues a +// small package for a trusted remote reviewer: the vehicle CROP cut out of the entry +// snapshot with the plate BLURRED, the operator's choice, and what the camera thought. +// The reviewer's verdict becomes the training label for the body-type classifier (phase +// B) and, per operator, the honest-mistake / fraud rate. +// +// Rules that shape this file: +// - OFFLINE-FIRST: the wash never waits. Enqueue is fire-and-forget off the intake path; +// a background loop drains the queue when the private overlay (Netbird) is up, with +// backoff, and gives up loudly after EXPIRE_DAYS. +// - ONE-WAY: the booth POSTs; nothing ever comes back into the booth's decisions. The +// signed ledger stays the only record of what happened at the wash. +// - NOTHING THAT NAMES THE SITE LEAVES: only the crop (no walls, no camera OSD, no +// bystanders), the plate blurred in place, a per-booth pseudonymous id set at deploy, +// the operator as a keyed hash. The mapping back to people and places stays with the +// reviewer, off the collector. +// - THE NETWORK IS NOT THE AUTH: a per-booth bearer token on top of the overlay; the +// booth can do nothing at the collector but this one POST. + +export interface ReviewUploadConfig { + /** The collector's ingest URL (reachable only over the overlay). */ + readonly url: string; + /** Per-booth bearer token. */ + readonly token: string; + /** Pseudonymous booth id — a label the reviewer maps to a site; never the site name. */ + readonly boothId: string; + readonly intervalSec: number; +} + +/** From the server env (Komodo stack env). All three of URL, token and booth id, or off. */ +export function reviewUploadConfigFromEnv(env: NodeJS.ProcessEnv = process.env): ReviewUploadConfig | null { + const url = (env.CARWASH_REVIEW_URL ?? "").trim(); + const token = (env.CARWASH_REVIEW_TOKEN ?? "").trim(); + const boothId = (env.CARWASH_REVIEW_BOOTH_ID ?? "").trim(); + if (!url || !token || !boothId) return null; + const raw = Number(env.CARWASH_REVIEW_INTERVAL_SEC ?? 60); + return { url, token, boothId, intervalSec: Number.isFinite(raw) && raw >= 10 ? raw : 60 }; +} + +/** The crop's longest edge, in pixels — enough for a reviewer and a classifier, small + * enough that a day of washes is a few megabytes. */ +export const CROP_MAX_EDGE = 640; +/** Margin around the detector's box, as a fraction of the box (context for the reviewer). */ +const CROP_MARGIN = 0.08; +/** Items older than this are abandoned (failed "expired") — a booth cut off for two weeks + * should not resurface a fortnight of crops in one burst. */ +export const EXPIRE_DAYS = 14; +/** Backoff: 1 min · 2^attempts, capped. */ +const BACKOFF_BASE_MS = 60_000; +const BACKOFF_CAP_MS = 6 * 60 * 60 * 1000; +const UPLOAD_TIMEOUT_MS = 20_000; + +/** What one order contributes to the package (the service hands this over at intake). */ +export interface ReviewItemInput { + readonly orderId: string; + readonly createdAt: string; + readonly createdBy: string; + readonly categoryId: string; + readonly categoryName: string; + readonly serviceName: string; + readonly visionCategoryId: string | null; + readonly downgraded: boolean; +} + +/** + * Cut the vehicle out of the snapshot and blur the plate inside it. Boxes are fractions + * of the frame, so this works on the stored (downscaled) copy. Returns a JPEG. + */ +export async function makeReviewCrop( + snapshotBytes: Buffer, + box: NormBox, + plateBox: NormBox | null | undefined, +): Promise<{ bytes: Buffer; width: number; height: number; plateBlurred: boolean }> { + const img = sharp(snapshotBytes, { failOn: "none" }).rotate(); + const meta = await img.metadata(); + const W = meta.width ?? 0; + const H = meta.height ?? 0; + if (!W || !H) throw new Error("snapshot has no dimensions"); + const px = (b: NormBox) => ({ + left: Math.round(b.x1 * W), top: Math.round(b.y1 * H), + right: Math.round(b.x2 * W), bottom: Math.round(b.y2 * H), + }); + const v = px(box); + const mw = Math.round((v.right - v.left) * CROP_MARGIN); + const mh = Math.round((v.bottom - v.top) * CROP_MARGIN); + const left = Math.max(0, v.left - mw); + const top = Math.max(0, v.top - mh); + const right = Math.min(W, v.right + mw); + const bottom = Math.min(H, v.bottom + mh); + const width = right - left; + const height = bottom - top; + if (width < 8 || height < 8) throw new Error("vehicle box too small to crop"); + + let crop = img.clone().extract({ left, top, width, height }); + let plateBlurred = false; + if (plateBox) { + // The plate region, in CROP coordinates, padded a little so the blur eats the edges. + const p = px(plateBox); + const pad = Math.round(Math.max(p.right - p.left, p.bottom - p.top) * 0.25); + const pl = Math.max(0, p.left - pad - left); + const pt = Math.max(0, p.top - pad - top); + const pr = Math.min(width, p.right + pad - left); + const pb = Math.min(height, p.bottom + pad - top); + if (pr - pl >= 2 && pb - pt >= 2) { + const region = await sharp(await crop.clone().toBuffer()) + .extract({ left: pl, top: pt, width: pr - pl, height: pb - pt }) + .blur(Math.max(6, Math.round((pr - pl) / 6))) + .toBuffer(); + crop = sharp(await crop.toBuffer()).composite([{ input: region, left: pl, top: pt }]); + plateBlurred = true; + } + } + const out = await crop + .resize({ width: CROP_MAX_EDGE, height: CROP_MAX_EDGE, fit: "inside", withoutEnlargement: true }) + .jpeg({ quality: 85, mozjpeg: true }) + .toBuffer({ resolveWithObject: true }); + return { bytes: out.data, width: out.info.width, height: out.info.height, plateBlurred }; +} + +/** The operator as a keyed hash — stable per booth so the reviewer can count per person, + * meaningless anywhere else. */ +export function operatorRef(boothId: string, username: string): string { + return createHash("sha256").update(`${boothId}:${username}`).digest("hex").slice(0, 16); +} + +type FetchLike = (input: string, init: RequestInit) => Promise; + +export interface OutboxStatus { + readonly enabled: boolean; + readonly boothId: string | null; + readonly queued: number; + readonly sent: number; + readonly failed: number; + readonly lastSentAt: string | null; + readonly lastError: string | null; +} + +export class ReviewOutbox { + readonly #db: Db; + readonly #logger: FastifyBaseLogger; + readonly #cfg: ReviewUploadConfig | null; + readonly #fetch: FetchLike; + #timer: NodeJS.Timeout | null = null; + #draining = false; + + constructor(db: Db, logger: FastifyBaseLogger, cfg: ReviewUploadConfig | null, fetchFn?: FetchLike) { + this.#db = db; + this.#logger = logger; + this.#cfg = cfg; + this.#fetch = fetchFn ?? ((input, init) => fetch(input, init)); + } + + get enabled(): boolean { + return this.#cfg != null; + } + + /** Queue one order's package. Fire-and-forget: the caller does NOT await this on the + * intake path; every failure is logged, none is thrown. Skipped when there is no + * vehicle box (nothing to crop — a frame without a detected vehicle is no training + * sample) or when upload is not configured (an unbounded queue nobody drains). */ + async enqueue(item: ReviewItemInput, read: VehicleRead): Promise { + if (!this.#cfg) return false; + if (!read.box || !read.snapshotId) return false; + try { + const snap = this.#db.select().from(snapshots).where(eq(snapshots.id, read.snapshotId)).get(); + if (!snap) { + this.#logger.info(`carwash review: snapshot ${read.snapshotId} gone (pruned) — order ${item.orderId} not queued`); + return false; + } + const crop = await makeReviewCrop(snap.bytes, read.box, read.plateBox); + const id = randomUUID(); + const payload = { + v: 1, + booth: this.#cfg.boothId, + item: id, + order: item.orderId, + at: item.createdAt, + operator: operatorRef(this.#cfg.boothId, item.createdBy), + operatorCategory: { id: item.categoryId, name: item.categoryName }, + service: item.serviceName, + vision: { class: read.bodyType, confidence: read.confidence, categoryId: item.visionCategoryId }, + downgraded: item.downgraded, + image: { width: crop.width, height: crop.height, plateBlurred: crop.plateBlurred }, + }; + this.#db + .insert(carwashReviewOutbox) + .values({ id, orderId: item.orderId, createdAt: new Date().toISOString(), status: "queued", attempts: 0, nextAttemptAt: null, image: crop.bytes, payload }) + .run(); + return true; + } catch (err) { + this.#logger.warn(`carwash review: could not queue order ${item.orderId}: ${(err as Error).message}`); + return false; + } + } + + start(): void { + if (!this.#cfg || this.#timer) return; + const tick = () => { + void this.drain().catch((err) => this.#logger.warn(`carwash review: drain failed: ${(err as Error).message}`)); + }; + this.#timer = setInterval(tick, this.#cfg.intervalSec * 1000); + this.#timer.unref?.(); + setTimeout(tick, 5_000).unref?.(); + } + + stop(): void { + if (this.#timer) clearInterval(this.#timer); + this.#timer = null; + } + + /** Send what is due, oldest first. Returns the tally; never throws for a single item. */ + async drain(limit = 20): Promise<{ sent: number; failed: number; deferred: number }> { + const tally = { sent: 0, failed: 0, deferred: 0 }; + if (!this.#cfg || this.#draining) return tally; + this.#draining = true; + try { + const now = new Date().toISOString(); + const due = this.#db + .select() + .from(carwashReviewOutbox) + .where(and(eq(carwashReviewOutbox.status, "queued"), or(isNull(carwashReviewOutbox.nextAttemptAt), lte(carwashReviewOutbox.nextAttemptAt, now)))) + .orderBy(asc(carwashReviewOutbox.createdAt)) + .limit(limit) + .all(); + for (const row of due) { + const outcome = await this.#send(row); + tally[outcome] += 1; + } + if (tally.sent || tally.failed) this.#logger.info(`carwash review: sent ${tally.sent}, failed ${tally.failed}, deferred ${tally.deferred}`); + } finally { + this.#draining = false; + } + return tally; + } + + async #send(row: typeof carwashReviewOutbox.$inferSelect): Promise<"sent" | "failed" | "deferred"> { + const cfg = this.#cfg!; + const ageMs = Date.now() - Date.parse(row.createdAt); + if (ageMs > EXPIRE_DAYS * 24 * 60 * 60 * 1000) return this.#fail(row, `expired after ${EXPIRE_DAYS} days`); + // A wash voided before delivery is not a sample (and not a decision to review). + const order = this.#db.select({ status: carwashOrders.status }).from(carwashOrders).where(eq(carwashOrders.id, row.orderId)).get(); + if (order?.status === "void") return this.#fail(row, "order voided"); + if (!row.image) return this.#fail(row, "image missing"); + + const form = new FormData(); + form.set("meta", JSON.stringify(row.payload)); + form.set("image", new Blob([new Uint8Array(row.image)], { type: "image/jpeg" }), `${row.id}.jpg`); + const ac = new AbortController(); + const t = setTimeout(() => ac.abort(), UPLOAD_TIMEOUT_MS); + try { + const res = await this.#fetch(cfg.url, { + method: "POST", + headers: { authorization: `Bearer ${cfg.token}`, "x-booth-id": cfg.boothId }, + body: form, + signal: ac.signal, + }); + if (res.ok) { + this.#db + .update(carwashReviewOutbox) + .set({ status: "sent", sentAt: new Date().toISOString(), image: null, lastError: null, attempts: row.attempts + 1 }) + .where(eq(carwashReviewOutbox.id, row.id)) + .run(); + return "sent"; + } + // The collector refused the package itself → no retry will help. + if ([400, 404, 413, 415, 422].includes(res.status)) return this.#fail(row, `rejected: HTTP ${res.status}`); + // Everything else (auth not yet fixed, throttled, collector down) → try again later. + return this.#defer(row, `HTTP ${res.status}`); + } catch (err) { + return this.#defer(row, (err as Error).name === "AbortError" ? "timeout" : (err as Error).message); + } finally { + clearTimeout(t); + } + } + + #fail(row: typeof carwashReviewOutbox.$inferSelect, why: string): "failed" { + this.#db + .update(carwashReviewOutbox) + .set({ status: "failed", lastError: why, image: null, attempts: row.attempts + 1 }) + .where(eq(carwashReviewOutbox.id, row.id)) + .run(); + this.#logger.warn(`carwash review: item ${row.id} (order ${row.orderId}) abandoned — ${why}`); + return "failed"; + } + + #defer(row: typeof carwashReviewOutbox.$inferSelect, why: string): "deferred" { + const attempts = row.attempts + 1; + const wait = Math.min(BACKOFF_BASE_MS * 2 ** Math.min(attempts, 20), BACKOFF_CAP_MS); + this.#db + .update(carwashReviewOutbox) + .set({ attempts, lastError: why, nextAttemptAt: new Date(Date.now() + wait).toISOString() }) + .where(eq(carwashReviewOutbox.id, row.id)) + .run(); + return "deferred"; + } + + status(): OutboxStatus { + const count = (s: "queued" | "sent" | "failed") => + this.#db.select({ n: sql`count(*)` }).from(carwashReviewOutbox).where(eq(carwashReviewOutbox.status, s)).get()?.n ?? 0; + const lastSent = this.#db.select({ at: sql`max(${carwashReviewOutbox.sentAt})` }).from(carwashReviewOutbox).get()?.at ?? null; + const lastErr = this.#db + .select({ e: carwashReviewOutbox.lastError }) + .from(carwashReviewOutbox) + .where(sql`${carwashReviewOutbox.lastError} is not null`) + .orderBy(sql`coalesce(${carwashReviewOutbox.sentAt}, ${carwashReviewOutbox.nextAttemptAt}, ${carwashReviewOutbox.createdAt}) desc`) + .limit(1) + .get()?.e ?? null; + return { + enabled: this.enabled, + boothId: this.#cfg?.boothId ?? null, + queued: count("queued"), + sent: count("sent"), + failed: count("failed"), + lastSentAt: lastSent, + lastError: lastErr, + }; + } +} diff --git a/apps/server/src/modules/carwash/routes.ts b/apps/server/src/modules/carwash/routes.ts index 87bf7f7..fcecda6 100644 --- a/apps/server/src/modules/carwash/routes.ts +++ b/apps/server/src/modules/carwash/routes.ts @@ -4,6 +4,7 @@ import { requireAnyPermission, requirePermission } from "../../auth.js"; import { requireModule } from "../../modules.js"; import { NoShiftOpenError } from "../../shift-service.js"; import type { ServerModuleDeps } from "../index.js"; +import type { ReviewOutbox } from "./review-outbox.js"; import { CarwashError, CarwashService, isPayAt, type SettingsBody } from "./service.js"; // HTTP surface of the Car Wash module. Every route is behind the venue-module gate @@ -27,7 +28,7 @@ function sendError(reply: FastifyReply, err: unknown): FastifyReply { throw err; } -export async function carwashRoutes(app: FastifyInstance, deps: ServerModuleDeps, service: CarwashService): Promise { +export async function carwashRoutes(app: FastifyInstance, deps: ServerModuleDeps, service: CarwashService, outbox?: ReviewOutbox): Promise { const moduleOn = requireModule(deps.db, "carwash"); // The price list is the desk's working data as much as Setup's: the wash operator // reads it under the module's own permission (the Wash operator job holds no site:*). @@ -38,6 +39,11 @@ export async function carwashRoutes(app: FastifyInstance, deps: ServerModuleDeps const update = [moduleOn, requirePermission("carwash:update")]; app.get("/api/carwash/settings", { preHandler: settingsRead }, async () => service.settings()); + // The review outbox's health (Setup → Car wash): how many decisions wait for the + // reviewer, how many went, the last error. Site admin's read. + app.get("/api/carwash/review/status", { preHandler: settingsRead }, async () => + outbox?.status() ?? { enabled: false, boothId: null, queued: 0, sent: 0, failed: 0, lastSentAt: null, lastError: null }, + ); app.put<{ Body: SettingsBody }>("/api/carwash/settings", { preHandler: settingsWrite }, async (req, reply) => { try { diff --git a/apps/server/src/modules/carwash/service.ts b/apps/server/src/modules/carwash/service.ts index dee33b2..e8b4b03 100644 --- a/apps/server/src/modules/carwash/service.ts +++ b/apps/server/src/modules/carwash/service.ts @@ -33,6 +33,7 @@ import { } from "@parking/shared"; import type { EventLog } from "../../event-log.js"; import { vehicleForIdentity } from "../../plate-lookup.js"; +import type { ReviewOutbox } from "./review-outbox.js"; import { effectiveModulesFor } from "../../modules.js"; import type { ChargeProvider, PayStation } from "../../pay-station.js"; import type { ShiftService } from "../../shift-service.js"; @@ -116,13 +117,15 @@ export class CarwashService { readonly #pay: PayStation; readonly #shift: ShiftService; readonly #logger: FastifyBaseLogger; + readonly #outbox: ReviewOutbox | null; - constructor(deps: ServerModuleDeps, logger: FastifyBaseLogger) { + constructor(deps: ServerModuleDeps, logger: FastifyBaseLogger, outbox: ReviewOutbox | null = null) { this.#db = deps.db; this.#log = deps.eventLog; this.#pay = deps.payStation; this.#shift = deps.shiftService; this.#logger = logger; + this.#outbox = outbox; } #enabled(): boolean { @@ -523,6 +526,22 @@ export class CarwashService { downgradeEventId, }; this.#db.insert(carwashOrders).values(row).run(); + // Hand the decision to the remote reviewer (crop + choice), off the intake path. + if (vision && this.#outbox?.enabled) { + void this.#outbox.enqueue( + { + orderId: row.id, + createdAt: now, + createdBy: input.actor, + categoryId: category.id, + categoryName: category.name, + serviceName: service.name, + visionCategoryId: visionCategory?.id ?? null, + downgraded: downgradeEventId != null, + }, + vision, + ); + } await this.#log.append({ type: "carwash_order", source: "manual", diff --git a/apps/server/src/plate-lookup.ts b/apps/server/src/plate-lookup.ts index 2deb5cd..8f08b12 100644 --- a/apps/server/src/plate-lookup.ts +++ b/apps/server/src/plate-lookup.ts @@ -1,5 +1,5 @@ import { and, desc, deviceEvents, eq, type Db } from "@parking/db"; -import { isVehicleClass, type VehicleRead } from "@parking/shared"; +import { isNormBox, isVehicleClass, type VehicleRead } from "@parking/shared"; // READ-TIME plate resolution. A recognized licence plate is ADVISORY evidence — it // lives in the unsigned, prunable `device_events` (kind="read") stream written by the @@ -27,6 +27,8 @@ interface ReadDetail { bodyType?: string; bodyConfidence?: number; snapshotId?: string; + vehicleBox?: unknown; + plateBox?: unknown; } /** The advisory VEHICLE read (body type) for a session — the same stream and the same @@ -43,7 +45,13 @@ export function vehicleForIdentity(db: Db, identity: string): VehicleRead | null for (const r of rows) { const d = (r.detail ?? {}) as ReadDetail; if (d.identity !== identity || !isVehicleClass(d.bodyType) || typeof d.bodyConfidence !== "number") continue; - const v: VehicleRead = { bodyType: d.bodyType, confidence: d.bodyConfidence, snapshotId: d.snapshotId ?? null }; + const v: VehicleRead = { + bodyType: d.bodyType, + confidence: d.bodyConfidence, + snapshotId: d.snapshotId ?? null, + box: isNormBox(d.vehicleBox) ? d.vehicleBox : null, + plateBox: isNormBox(d.plateBox) ? d.plateBox : null, + }; if (d.direction === "entry") return v; if (!fallback) fallback = v; } diff --git a/apps/server/src/snapshot.ts b/apps/server/src/snapshot.ts index 8f69f2c..65c072f 100644 --- a/apps/server/src/snapshot.ts +++ b/apps/server/src/snapshot.ts @@ -168,13 +168,26 @@ async function recognizePlate( try { const result = await vision.analyze(shot.bytes, shot.contentType); if (!result) return; + // Boxes are kept as FRACTIONS of the analysed frame (the stored snapshot is a + // downscaled copy — see reencodeForStorage), so the wash's review crop can cut the + // vehicle out of whatever copy survives and blur the plate inside it. + const frame = await frameSize(shot.bytes); + const norm = (b: { x1: number; y1: number; x2: number; y2: number } | null | undefined) => + b && frame + ? { + x1: clamp01(b.x1 / frame.w), y1: clamp01(b.y1 / frame.h), + x2: clamp01(b.x2 / frame.w), y2: clamp01(b.y2 / frame.h), + } + : null; // The vehicle's body type (advisory; the wash desk's category suggestion — see // venue-modules.md §Vehicle category). Rides the plate's read row when there is one, // else a row of its own: a car with an unreadable plate is still a car of some class. + const vehicleBox = norm(result.vehicle?.bbox); const vehicle = result.vehicle - ? { bodyType: result.vehicle.bodyType, bodyConfidence: result.vehicle.confidence } + ? { bodyType: result.vehicle.bodyType, bodyConfidence: result.vehicle.confidence, ...(vehicleBox ? { vehicleBox } : {}) } : {}; const plate = !result.plate || result.lowConfidence ? "" : result.plate.text.trim().toUpperCase(); + const plateBox = plate ? norm(result.plate?.bbox) : null; if (!plate && !result.vehicle) return; // nothing trustworthy to record db.insert(deviceEventsTable) .values({ @@ -187,7 +200,7 @@ async function recognizePlate( identity, direction, ...(plate - ? { plate, confidence: result.plate!.confidence, region: result.plate!.region ?? null } + ? { plate, confidence: result.plate!.confidence, region: result.plate!.region ?? null, ...(plateBox ? { plateBox } : {}) } : {}), ...vehicle, modelVersion: result.modelVersion, @@ -215,6 +228,20 @@ async function recognizePlate( } } +function clamp01(v: number): number { + return Math.max(0, Math.min(1, v)); +} + +/** Pixel size of the analysed frame (JPEG header only — cheap). Null when unreadable. */ +async function frameSize(bytes: Buffer): Promise<{ w: number; h: number } | null> { + try { + const m = await sharp(bytes, { failOn: "none" }).metadata(); + return m.width && m.height ? { w: m.width, h: m.height } : null; + } catch { + return null; + } +} + /** How far back a recognized entry plate is compared against other OPEN sessions' * entry plates. Short on purpose: the duplicate-ticket scenario is the same car * re-pressing within minutes; a long window would flag legit re-visits. */ diff --git a/apps/server/src/vision-client.ts b/apps/server/src/vision-client.ts index b532d55..60beefb 100644 --- a/apps/server/src/vision-client.ts +++ b/apps/server/src/vision-client.ts @@ -48,13 +48,15 @@ export interface VisionPlate { export interface VisionVehicle { readonly bodyType: VehicleClass; readonly confidence: number; + /** The vehicle's box in frame pixels, when the stage found one. */ + readonly bbox?: PlateBBox | null; } /** The raw /analyze response shape (the Python contract). */ interface AnalyzeResponse { readonly plate: VisionPlate | null; readonly plates: VisionPlate[]; - readonly vehicle: { body_type?: string | null; confidence?: number | null } | null; + readonly vehicle: { body_type?: string | null; confidence?: number | null; bbox?: PlateBBox | null } | null; readonly low_confidence: boolean; readonly model_version: string; readonly took_ms: number; @@ -138,7 +140,7 @@ export class VisionClient { const v = res.vehicle; const vehicle: VisionVehicle | null = v && isVehicleClass(v.body_type) && typeof v.confidence === "number" - ? { bodyType: v.body_type, confidence: Math.max(0, Math.min(1, v.confidence)) } + ? { bodyType: v.body_type, confidence: Math.max(0, Math.min(1, v.confidence)), bbox: v.bbox ?? null } : null; return { plate: best, diff --git a/apps/vision/vision_service/schemas.py b/apps/vision/vision_service/schemas.py index 932b849..0e2dc01 100644 --- a/apps/vision/vision_service/schemas.py +++ b/apps/vision/vision_service/schemas.py @@ -43,6 +43,8 @@ class VehicleResult(BaseModel): body_type: str | None = None # Confidence of `body_type` (0–1). Node compares it to the site's threshold. confidence: float | None = Field(default=None, ge=0.0, le=1.0) + # The vehicle's box in frame pixels — the crop a reviewer sees / a classifier eats. + bbox: BBox | None = None make: str | None = None model: str | None = None diff --git a/apps/vision/vision_service/vehicle.py b/apps/vision/vision_service/vehicle.py index 3b1f309..7354d71 100644 --- a/apps/vision/vision_service/vehicle.py +++ b/apps/vision/vision_service/vehicle.py @@ -235,7 +235,11 @@ class YoloxVehicleDetector: best = pick_vehicle(found, plate) if best is None: return None - return VehicleResult(body_type=best.body_type, confidence=round(best.confidence, 4)) + h, w = frame.shape[:2] + box = BBox( + x1=max(0, int(best.x1)), y1=max(0, int(best.y1)), x2=min(w, int(best.x2)), y2=min(h, int(best.y2)) + ) + return VehicleResult(body_type=best.body_type, confidence=round(best.confidence, 4), bbox=box) def time_detect( diff --git a/apps/web/src/lib/i18n/en.ts b/apps/web/src/lib/i18n/en.ts index a0b74c5..6743aeb 100644 --- a/apps/web/src/lib/i18n/en.ts +++ b/apps/web/src/lib/i18n/en.ts @@ -140,6 +140,10 @@ export const en: Catalog = { visionSaw: "Camera saw", visionUnmapped: "not mapped to a category", visionClasses: "Camera classes", + reviewTitle: "Remote review", + reviewOff: "off — no collector configured for this booth", + reviewCounts: "{{queued}} waiting · {{sent}} delivered · {{failed}} abandoned", + reviewHint: "Each wash order sends the vehicle crop (plate blurred) and the chosen category to a trusted reviewer over the private network. One-way; nothing that names this site leaves.", visionClassesHint: "The camera's fixed vocabulary (set in code, not here). Tick the classes this category covers.", visionThreshold: "Camera confidence to flag a downgrade", visionThresholdHint: "When the camera is at least this sure and the operator picks a cheaper category than the one its class maps to, the order is flagged for review. It is never blocked.", diff --git a/apps/web/src/lib/i18n/sq.ts b/apps/web/src/lib/i18n/sq.ts index 4cb287e..2343a70 100644 --- a/apps/web/src/lib/i18n/sq.ts +++ b/apps/web/src/lib/i18n/sq.ts @@ -142,6 +142,10 @@ export const sq = { visionSaw: "Kamera pa", visionUnmapped: "pa kategori të lidhur", visionClasses: "Klasat e kamerës", + reviewTitle: "Shqyrtim në distancë", + reviewOff: "joaktiv — asnjë mbledhës i konfiguruar për këtë kabinë", + reviewCounts: "{{queued}} në pritje · {{sent}} të dërguara · {{failed}} të braktisura", + reviewHint: "Çdo porosi lavazhi dërgon prerjen e mjetit (targa e turbulluar) dhe kategorinë e zgjedhur te një shqyrtues i besuar përmes rrjetit privat. Njëkahësh; asgjë që emërton këtë vend nuk del.", visionClassesHint: "Fjalori i fiksuar i kamerës (vendoset në kod, jo këtu). Shëno klasat që mbulon kjo kategori.", visionThreshold: "Siguria e kamerës për të shënuar një ulje kategorie", visionThresholdHint: "Kur kamera është të paktën kaq e sigurt dhe operatori zgjedh një kategori më të lirë se ajo ku lidhet klasa, porosia shënohet për shqyrtim. Nuk bllokohet kurrë.", diff --git a/apps/web/src/modules/carwash/CarWashSetup.tsx b/apps/web/src/modules/carwash/CarWashSetup.tsx index ab4f75c..d903cbd 100644 --- a/apps/web/src/modules/carwash/CarWashSetup.tsx +++ b/apps/web/src/modules/carwash/CarWashSetup.tsx @@ -3,7 +3,7 @@ import { useTranslation } from "react-i18next"; import { CARWASH_PAY_AT, CARWASH_PROGRAM_ID, CARWASH_VALIDATION_MODES, VEHICLE_CLASSES, type CarWashPayAt, type VehicleClass } from "@parking/shared"; import { fetchValidationPrograms, type ValidationProgramView } from "../../api.js"; import { StationForm, defaultProgram } from "../../ValidationSetup.js"; -import { fetchCarwashSettings, saveCarwashSettings, type CarwashSettingsView } from "./api.js"; +import { fetchCarwashReviewStatus, fetchCarwashSettings, saveCarwashSettings, type CarwashReviewStatus, type CarwashSettingsView } from "./api.js"; // Setup → Car wash: the master data (vehicle categories, services, the category × // service price matrix) and the parking SPONSORSHIP a wash grants — the latter is a @@ -113,6 +113,7 @@ export function CarWashSetup({ canEdit }: { canEdit: boolean }) { const [threshold, setThreshold] = useState("80"); const [msg, setMsg] = useState(null); const [program, setProgram] = useState(null); + const [review, setReview] = useState(null); function load() { fetchCarwashSettings() @@ -127,6 +128,7 @@ export function CarWashSetup({ canEdit }: { canEdit: boolean }) { setThreshold(String(Math.round(s.visionThreshold * 100))); }) .catch((e) => setMsg((e as Error).message)); + fetchCarwashReviewStatus().then(setReview).catch(() => {}); fetchValidationPrograms() .then((r) => { const existing = r.programs.find((p) => p.id === CARWASH_PROGRAM_ID); @@ -265,6 +267,18 @@ export function CarWashSetup({ canEdit }: { canEdit: boolean }) { {t("wash.visionThresholdHint")} + {review && ( +
+ {t("wash.reviewTitle")} + + {review.enabled + ? t("wash.reviewCounts", { queued: review.queued, sent: review.sent, failed: review.failed }) + : t("wash.reviewOff")} + {review.enabled && review.lastError && {review.lastError}} + + {t("wash.reviewHint")} +
+ )} {canEdit && (
diff --git a/apps/web/src/modules/carwash/api.ts b/apps/web/src/modules/carwash/api.ts index b41be51..6d4a68c 100644 --- a/apps/web/src/modules/carwash/api.ts +++ b/apps/web/src/modules/carwash/api.ts @@ -31,6 +31,20 @@ export interface CarwashSettingsBody { visionThreshold?: number; } +/** The review outbox's health (Setup → Car wash). */ +export interface CarwashReviewStatus { + enabled: boolean; + boothId: string | null; + queued: number; + sent: number; + failed: number; + lastSentAt: string | null; + lastError: string | null; +} +export function fetchCarwashReviewStatus(): Promise { + return apiFetch("/api/carwash/review/status"); +} + export function fetchCarwashSettings(): Promise { return apiFetch("/api/carwash/settings"); } diff --git a/docker-compose.yml b/docker-compose.yml index fad0807..5fcc985 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -28,6 +28,11 @@ services: # off (the in-UI target + retention do nothing without it). Per-booth + unique; escrow it # offsite. See apps/server/.env.example + wiki/concepts/backup-recovery.md. BACKUP_KEY: ${BACKUP_KEY:-} + # Car Wash review outbox: collector URL + per-booth token + pseudonymous booth id, all + # three or off. See apps/server/.env.example + wiki/concepts/vision-review-outbox.md. + CARWASH_REVIEW_URL: ${CARWASH_REVIEW_URL:-} + CARWASH_REVIEW_TOKEN: ${CARWASH_REVIEW_TOKEN:-} + CARWASH_REVIEW_BOOTH_ID: ${CARWASH_REVIEW_BOOTH_ID:-} # CRITICAL on the plain-HTTP booth LAN: cookies are Secure (HTTPS-only) by DEFAULT, # so without COOKIE_SECURE=0 the auth cookie is never sent over http and operators # CANNOT LOG IN. Leave unset only behind TLS. See disk-os-hardening "deploy-time runbook". diff --git a/packages/db/drizzle/0031_carwash_review_outbox.sql b/packages/db/drizzle/0031_carwash_review_outbox.sql new file mode 100644 index 0000000..7b1ac5c --- /dev/null +++ b/packages/db/drizzle/0031_carwash_review_outbox.sql @@ -0,0 +1,17 @@ +-- Car Wash review outbox (wiki/concepts/vision-review-outbox.md): plate-blurred vehicle +-- crops + the operator's category choice, queued for a trusted remote reviewer and drained +-- one-way over the private overlay. The image is cleared once delivered. +CREATE TABLE `carwash_review_outbox` ( + `id` text PRIMARY KEY NOT NULL, + `order_id` text NOT NULL, + `created_at` text NOT NULL, + `status` text DEFAULT 'queued' NOT NULL, + `attempts` integer DEFAULT 0 NOT NULL, + `next_attempt_at` text, + `last_error` text, + `sent_at` text, + `image` blob, + `payload` text NOT NULL +); +--> statement-breakpoint +CREATE INDEX `carwash_review_outbox_status_idx` ON `carwash_review_outbox` (`status`,`next_attempt_at`); diff --git a/packages/db/drizzle/meta/_journal.json b/packages/db/drizzle/meta/_journal.json index ce0a3bf..d153a39 100644 --- a/packages/db/drizzle/meta/_journal.json +++ b/packages/db/drizzle/meta/_journal.json @@ -218,6 +218,13 @@ "when": 1788700000000, "tag": "0030_carwash_vision", "breakpoints": true + }, + { + "idx": 31, + "version": "6", + "when": 1788710000000, + "tag": "0031_carwash_review_outbox", + "breakpoints": true } ] } diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index a5fafb1..2248c43 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -746,6 +746,26 @@ export const carwashConfig = sqliteTable("carwash_config", { updatedBy: text("updated_by"), }); +/** Car Wash REVIEW OUTBOX (wiki/concepts/vision-review-outbox.md): the operator's category + * choice is a hypothesis, not truth — each wash order with a vehicle read queues a + * plate-blurred vehicle CROP + the choice for a trusted remote reviewer, drained one-way + * over the private overlay when it is up. Never blocks the wash; nothing that names the + * site leaves the booth. The image is dropped once delivered. */ +export const carwashReviewOutbox = sqliteTable("carwash_review_outbox", { + id: text("id").primaryKey(), + orderId: text("order_id").notNull(), + createdAt: text("created_at").notNull(), + status: text("status", { enum: ["queued", "sent", "failed"] }).notNull().default("queued"), + attempts: integer("attempts").notNull().default(0), + nextAttemptAt: text("next_attempt_at"), + lastError: text("last_error"), + sentAt: text("sent_at"), + /** The JPEG crop (plate blurred). Null once sent. */ + image: blob("image").$type(), + /** What the collector receives beside the image (no site name, no plate, no operator name). */ + payload: text("payload", { mode: "json" }).$type>().notNull(), +}); + export type CarwashCategoryRow = typeof carwashCategories.$inferSelect; export type CarwashServiceRow = typeof carwashServices.$inferSelect; export type CarwashPriceRow = typeof carwashPrices.$inferSelect; diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 70858a9..4e0aaf1 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -2050,6 +2050,23 @@ export interface VehicleRead { readonly bodyType: VehicleClass; readonly confidence: number; readonly snapshotId: string | null; + /** The vehicle's box and the plate's box as FRACTIONS of the frame (0–1), so they fit + * any resized copy of the snapshot. Absent on reads made before boxes were kept. */ + readonly box?: NormBox | null; + readonly plateBox?: NormBox | null; +} + +/** A box as fractions of the frame it was found in (x1,y1 top-left; 0–1). */ +export interface NormBox { + readonly x1: number; + readonly y1: number; + readonly x2: number; + readonly y2: number; +} +export function isNormBox(v: unknown): v is NormBox { + if (!v || typeof v !== "object") return false; + const b = v as Record; + return ["x1", "y1", "x2", "y2"].every((k) => typeof b[k] === "number" && (b[k] as number) >= 0 && (b[k] as number) <= 1); } export interface CarwashSettingsView { diff --git a/wiki/concepts/vision-review-outbox.md b/wiki/concepts/vision-review-outbox.md new file mode 100644 index 0000000..77159d4 --- /dev/null +++ b/wiki/concepts/vision-review-outbox.md @@ -0,0 +1,69 @@ +--- +title: Vision review outbox — harvesting the operator's category choice for a trusted reviewer +type: concept +status: booth side built 2026-09-06; collector pending +related: [venue-modules, opencv-anpr-service, threat-model, append-only-event-chain, network-isolation] +--- + +# Vision review outbox + +**The idea (user, 2026-09-06).** The Car Wash desk asks the operator for the vehicle's category, +and the entry camera now proposes one ([[venue-modules]] §Vehicle category from vision). The +operator's choice is what we would love to train the body-type classifier on — but the +operator **cannot be fully trusted** (mistake or intent; the [[threat-model]]). So the booth +hands each decision to a **trusted party** who reviews the picture and the label remotely, +and *that* verdict is the training label — and, per operator, the honest-mistake / fraud rate. +The booths sit on a private zero-trust overlay (**Netbird**), so the hand-off can go to a very +locked-down collector without exposing anything to the open internet. + +## Rules (all enforced in `apps/server/src/modules/carwash/review-outbox.ts`) + +1. **Offline-first, never on the intake path.** Creating a wash order *queues* a package (fire + and forget — a failure is a log line); a background loop drains the queue when the overlay + is up. The wash never waits on the network. +2. **One-way.** The booth POSTs; nothing ever comes back into the booth's decisions. The signed + ledger ([[append-only-event-chain]]) stays the only record of what happened at the wash. + Reviewer verdicts stay central and reach the owner as a report per site. +3. **Nothing that names the site leaves the booth.** + - Only the vehicle **crop** (the detector's box + 8 % margin, ≤ 640 px) — no walls, no camera + OSD (date / camera name burned into the frame), no bystanders. + - The **plate is blurred inside the crop** on the booth, from the plate detector's own box. + - The booth is a **pseudonymous id** set at deploy (`CARWASH_REVIEW_BOOTH_ID`); the operator + is a **keyed hash** (`sha256(boothId:username)[:16]`). The mapping back to places and + people is the reviewer's, held off the collector. The dataset export drops even those. + - Boxes are stored as **fractions of the frame** on the vision read, so the crop is cut from + the stored (downscaled) snapshot copy. +4. **The network is not the auth.** A per-booth bearer token on top of the overlay; the booth + can do nothing at the collector but this one POST. Payloads are small (a crop ≈ 50–80 kB). +5. **Data minimisation.** Queued only when there is a vehicle box (no box = no sample); the + image is dropped from the row once delivered; a voided order is abandoned unsent; anything + older than 14 days is abandoned ("expired") rather than resurfacing a fortnight in a burst. + +## The package + +`multipart/form-data`: `meta` (JSON) + `image` (JPEG). Meta = `{ v, booth, item, order, at, +operator (hash), operatorCategory {id,name}, service, vision {class, confidence, categoryId}, +downgraded, image {width, height, plateBlurred} }`. Headers: `Authorization: Bearer `, +`X-Booth-Id`. + +## Draining + +Every `CARWASH_REVIEW_INTERVAL_SEC` (60): due items oldest-first, 20 per pass. `2xx` → sent +(image cleared). `400/404/413/415/422` → abandoned (the collector refused the package itself). +Anything else (auth not yet fixed, 429, 5xx, timeout, no route) → retry with backoff +`1 min · 2^attempts`, capped at 6 h. `GET /api/carwash/review/status` (site:read) and a line in +Setup → Car wash show queued / delivered / abandoned + the last error. + +## Config + +`CARWASH_REVIEW_URL`, `CARWASH_REVIEW_TOKEN`, `CARWASH_REVIEW_BOOTH_ID` — all three or the outbox +is off and **nothing is queued** (an unbounded queue nobody drains is worse than none). Set per +booth in the Komodo stack env; compose forwards them. + +## Not built yet: the collector + +A deliberately small service on the overlay: one ingest endpoint (token per booth, size cap), +one review screen (crop, the operator's pick, the camera's pick → the reviewer picks the truth), +one export (crops + reviewer labels, nothing else) for phase B training. Keep it that small — +it must not grow into a fleet console. The Netbird policy: booths may reach the collector's +ingest port and nothing else on it. diff --git a/wiki/decisions/venue-modules.md b/wiki/decisions/venue-modules.md index acd6921..de8096e 100644 --- a/wiki/decisions/venue-modules.md +++ b/wiki/decisions/venue-modules.md @@ -196,6 +196,10 @@ onto the site's own categories ("car, sedan, hatchback → Vetura"). dataset builds itself on park-2. Until then a Vetura/SUV list sees every car as Vetura and no downgrade fires; van/truck/bus/motorcycle do separate. Reports (discrepancies per operator per shift) wait for the first real reads. +- **The operator's label is a hypothesis (user, 2026-09-06)** — the training label is a trusted + remote reviewer's. Booth side built: [[vision-review-outbox]] (crop + blurred plate + choice, + queued off the intake path, drained one-way over Netbird, no site identity leaves). The + collector is the open half. ## Car Wash — the pilot module (settled 2026-09-05) diff --git a/wiki/index.md b/wiki/index.md index edbd1ac..20c40fb 100644 --- a/wiki/index.md +++ b/wiki/index.md @@ -57,6 +57,7 @@ Counts: 4 sources · 19 entities · 47 concepts · 8 decision records. - [[append-only-event-chain]] — append-only + hash chain + signing = unforgeable log (signing is **software today**; hardware signer pending — see below). - [[hardware-signer-options]] — where the ledger signing key should live (TPM interim → USB-HSM target; ATECC608 upcoming, not on-site) so a host-owner can't forge the chain. - [[reconciliation]] — the real anti-fraud control; what remote sync actually is. +- [[vision-review-outbox]] — the wash operator's category choice is a hypothesis: the booth queues a plate-blurred vehicle crop + the choice for a trusted remote reviewer over Netbird (one-way, offline-first, no site identity leaves); the verdict = training label + per-operator error/fraud rate. - [[disk-os-hardening]] — the *why* of host hardening: LUKS FDE + TPM-sealed auto-unlock (PCR 7) + Secure Boot + GRUB edit-lock + unprivileged operator + firmware/dbx lockdown; secondary control (reconciliation is the main event). Commands → [[appliance-provisioning]]. - [[backup-recovery]] — admin-driven encrypted full-DB backup (local/SMB/SFTP) + DR; signing key escrowed & decoupled from TPM so the ledger survives total hardware loss; restore is admin-only; last-success/error status + schedule are now restart-durable (migration 0025, fixed a "shows Never despite valid backups" bug). diff --git a/wiki/log.md b/wiki/log.md index f370c3e..a71e646 100644 --- a/wiki/log.md +++ b/wiki/log.md @@ -3100,3 +3100,13 @@ off), input size, detector floor; Dockerfile bakes yolox_s.onnx (best-effort cur path; compose forwards it (empty = off). Measured on four real dev entry frames: car at 0.83–0.88, ~240–330 ms, empty lane → none. Tests: tests/test_vehicle.py (pure post-processing + composition + missing-model health). Updated [[opencv-anpr-service]], [[venue-modules]]. + +## [2026-09-06] ingest | Car Wash review outbox — booth side +User: the operator cannot be fully trusted, so their category choice + the snapshot go to a +trusted remote reviewer over Netbird, with the plate blurred and no site identity. Built the booth +side: vision reads keep the vehicle and plate boxes as frame fractions (service bbox → client → +device_events); `carwash_review_outbox` (0031); `review-outbox.ts` (crop with margin ≤ 640 px, +plate blurred in place, pseudonymous booth id + keyed operator hash, multipart POST with a +per-booth bearer, backoff, permanent rejections, void/expiry abandon, image dropped once sent); +enqueue off the intake path in `createOrder`; `/api/carwash/review/status` + a Setup line; env + +compose. New concept page [[vision-review-outbox]]; [[venue-modules]] As built; index.