feat(carwash): review outbox, booth side — plate-blurred vehicle crop + the operator's choice, queued for a trusted remote reviewer
The operator's category choice is a hypothesis, not truth (user, 2026-09-06): each wash order with a vehicle read queues a package for a trusted reviewer over the private overlay (Netbird); the verdict becomes the phase-B training label and the per-operator error rate. wiki/concepts/vision-review-outbox.md. - Boxes: the vision service returns the vehicle bbox; snapshot.ts stores the vehicle and plate boxes on the read as FRACTIONS of the analysed frame (the stored snapshot is a downscaled copy); vehicleForIdentity() returns them. - carwash_review_outbox (migration 0031) + review-outbox.ts: crop = detector box + 8 % margin, ≤ 640 px, plate blurred in place from the plate box; payload carries a pseudonymous booth id and a keyed operator hash — no site name, no plate, no OSD, no bystanders; multipart POST with a per-booth bearer; 2xx → sent (image dropped); 400/404/413/415/422 → abandoned; anything else → backoff 1 min·2^n capped 6 h; voided orders and items older than 14 days abandoned unsent. Nothing queued while unconfigured. - Enqueue is fire-and-forget off the intake path in createOrder; the loop runs every CARWASH_REVIEW_INTERVAL_SEC (60) and stops on close. - GET /api/carwash/review/status (site:read) + a "Remote review" line in Setup → Car wash. - Env CARWASH_REVIEW_URL / _TOKEN / _BOOTH_ID (all three or off) documented in .env.example and forwarded by compose. - Tests: review-outbox.test.ts (crop + blur on a synthetic frame, config/pseudonyms, queue/drain/backoff/abandon, through the app). Wiki: new concept page, index, venue-modules As built, log. The collector is not built. Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
This commit is contained in:
@@ -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<Buffer> {
|
||||
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<number> {
|
||||
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<void> {
|
||||
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<string, string>).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 });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user