Fresclean POS · Chapter 2 of 8

Photos that settle arguments

Jovanus Hartono2026

On this page

The POS takes two kinds of photo. The drop-off photo is one per order, and it records the handover: who brought what across the counter. The Item photo records the condition of each pair, which the POS calls an Item, before work starts.

Both are for the day a customer tells the cashier that a mark wasn't there when they dropped the pair off. The only good answer to that is a photo taken before anyone touched the shoes. The two photos answer different questions, though, and they became rules of two very different strengths.

A rule on the counter, a rule on the server

The drop-off photo is required, but softly. The POS won't finish checkout until the cashier has taken one. Behind the scenes, though, the order is created first and the photo is uploaded in a separate request afterwards, and that upload is allowed to fail. If it does, the order detail page shows the photo as missing, with a button to try again.

That's deliberate. Taking a customer's order is the single most common thing that happens at the counter, and I didn't want a flaky upload to stop a sale. When I wrote the rule down, I put it more bluntly: "The proof isn't worth failing revenue capture."

The Item photo is the opposite. It's enforced on the server, and a treatment can't move from the queue into processing until its pair has a photo. Disabling the start button wouldn't have been enough. There are two ways to start work, one on the order page and one in the queue, and the status endpoint can be called directly too. So the check lives in the one function every status change goes through:

// Proof of condition before work starts.
if (from === "queued" && to === "processing") {
  await assertStartPhoto(executor, current);
}

assertStartPhoto looks up the pair's photos and throws "Add an item photo before starting work" if there are none. I wrote the reason down at the time: "A real rule belongs at the single write seam, not on one button."

Admins can't skip it either, and I gave that one line: "A gate admins can wave through is not a gate." The buttons are still disabled, with a hint to add a photo, but that's only there to explain the rule early. And since a photo can be taken while the pair is still waiting in the queue, there's no chicken and egg: photograph the pair, then start.

Three tries

Getting the photos themselves right took three tries.

The first version sent them up raw, around 6 MB each from an iPhone. That adds up fast, so the second re-encoded every photo on the server to 1600px WebP at quality 80.

Going back over the whole chain, two problems stood out. Quality 80 was smoothing away exactly the faint marks a dispute is usually about. And, a little embarrassingly, the camera had never asked for a resolution. When a page opens the camera with getUserMedia and names no size, the browser quietly settles on 640×480, so the evidence I'd been carefully compressing was only about 330 pixels wide.

2560px, quality 85stored now1600px, quality 80the second versionabout 330 × 480what was really stored
Photo sizes to scale. The second version resized to 1600px, but the photos it was compressing were only about 330 × 480. Photos are now stored at up to 2560px.

The camera now asks for 2560px, and photos are stored at 2560px and quality 85. The request uses ideal values, which the browser treats as a wish it gets as close to as it can, where exact would refuse to open the camera at all on a phone that can't manage it:

const stream = await navigator.mediaDevices.getUserMedia({
  video: {
    facingMode: { ideal: "environment" }, // the back camera
    width: { ideal: 2560 },
    height: { ideal: 2560 },
  },
  audio: false,
});

MDN's page on media constraints covers ideal and exact.

DeviceAPIS3scaled to 2560px on the deviceWebP, or JPEG on iPadspresign, ~45 msupload URLPUT, seconds on 4Gcommit, ~600 msread the headerWebP within 2560px and 4 MB: kept as sentanything else: re-encoded to 2560px WebPNightly: delete files no row points to, once a day old
The slow PUT starts as soon as a photo is staged, so it runs while the cashier shoots the next pair.

Uploading while you shoot

The same pass went after speed. Over a shop's 4G, an upload takes three requests:

RequestTime
Ask the API for an upload URLAbout 45 ms
Send the file itselfSeconds
Save the photoAbout 600 ms

A cashier photographing several pairs shouldn't have to wait on each one.

So the upload starts the moment a photo lands in the strip, and runs while the cashier shoots the next pair or types a note. The browser does the resizing before anything is sent. It draws the photo onto a canvas at the right size and encodes it with toBlob, as WebP where it can and JPEG on the iPads, because Safari can't encode WebP. A browser that can't encode WebP hands back a PNG without saying so, so the POS checks what it actually got:

const blob = await toBlob(canvas, "image/webp", 0.85);
if (blob?.type === "image/webp") {
  return { blob, extension: "webp", type: "image/webp" };
}
// Safari: fall back to JPEG, which the server converts as before

The server then reads only the file header, the first 64 KB. If it's already a WebP within 2560px and 4 MB, it's kept exactly as sent, so the evidence is never compressed twice.

Starting early has a cost. If the cashier never confirms the batch, its files are left orphaned in the A storage container for files in the cloud, like a folder on a remote disk., so a nightly job compares the bucket with the database and deletes files no row points to, once they're a day old. (I once made it delete the files behind deleted photos too, and reverted that. A deleted photo is only hidden now, and its file stays.)

One photo, three checks

When I designed Items, I left photos on the treatment on purpose. A photo before each treatment seemed like the right reading of the rule.

It wasn't. When I wrote down the change, I started from the old rule and then wrote "Shop practice disagrees." The photo is a record of the object, taken at drop-off, mostly by the cashier. A worker only adds one when they find something worth recording, and nobody photographs a treatment. So a pair booked for three treatments had been carrying three separate galleries, and clearing three photo checks with what was really one shot.

Photos now belong to the pair. Every treatment on it reads the same gallery and the same check, so the second worker, the one doing the repaint, doesn't need a photo of their own. The files already in the bucket stayed where they were. The nightly cleanup keeps whatever path the database points to and never cares how a path is laid out, so nothing had to be moved.

There's one exception. When a customer brings a pair back with a complaint, the photos from its first visit don't count, because they don't show the shoes as they came back. The worker has to take a new one first. Complaints are their own chapter.

Photos after a treatment are out of scope, too. The shop takes those on separate devices for marketing, and the POS doesn't need to know about them.