AI Product Engineering
Forward a receipt, get your shop scored
For UK adults with prediabetes. Forward a supermarket receipt: AI rates every item, scores the basket, suggests swaps and tracks progress. Free once, then monthly or yearly.
Stack
- React
- Node.js
- Typescript
- Supabase
- PostgreSQL
- Stripe
- PostHog
- Loveable
5
paid subscribers
first 14 days live
8
health exclusions
checked before any AI runs
17
screens shipped
11 behind the login

The product
What the app is
PrediabetesBasket is a UK app for adults living with prediabetes. You forward your supermarket receipt to a private email address, and minutes later the app shows what your shop actually looks like: every item traffic-lighted green, amber or red, a basket score out of 100, a healthier swap for anything flagged, and a short written takeaway. Shop again next week and it charts whether things are improving.
Everyone gets one free basket check; after that it runs on a monthly or yearly plan.

1
email address is the entire input surface
No camera, no barcode scanner, no manual entry, no upload button anywhere in the app.
That one decision removed a whole category of product work — capture screens, OCR retries, camera permissions — and replaced it with a subtler problem. The user has to trust an address they cannot watch working, so a good half of the app exists to answer one question: did it arrive, and is anything happening yet?
Scope
Where this app ends and the AI begins
Two systems, one product. This study is the app; the sibling study is the engine behind it.
The analysis itself does not happen here. A separate pipeline receives the forwarded email, reads the receipt, runs it through the model and emails the finished result straight back — so the user gets their answer in their inbox whether or not they ever open the app. That pipeline is the sibling case study.
What this app adds is everything an email cannot do: history, filters, a score trend across weeks, per-item reasoning you can expand, the subscription that pays for it, and the safety and legal surfaces a UK health product needs. I built both halves; this is the one people log into.
One receipt, end to end
Sign up and onboard — six steps, saved at three checkpoints
Get a private inbound address — provisioned automatically, one per user
Forward a supermarket receipt — no camera, no upload
The pipeline takes over — sibling case study
health exclusion → referral message, and the model is never called
Analysis lands in the inbox — the product’s actual deliverable
The app updates itself — no refresh needed
Explore, compare, track — history, per-item reasoning, score trend
Steps four and five are the sibling study. Everything either side of them is this one.
Onboarding
Signing up, and who the app turns away
Email and password only. Google and Apple sign-in were built and then deliberately taken out — the account is tied to a per-user inbound email identity, so a social login adds an outside dependency and a consent conversation to a screen most people see exactly once.
Onboarding runs six steps: welcome, how it works, eligibility, safety screening, preferences, consent. It saves at three checkpoints rather than once at the end, so someone who gets halfway through and comes back tomorrow picks up where they left off.
The safety gate is a database column, not an if-statement
Eight health conditions mean the app should not give someone personalised food advice. Where does that rule live?
In Postgres. Whether someone has an exclusion, and whether they can receive personalised analysis, are both generated columns — derived automatically from their onboarding answers. Application code physically cannot write them; trying to is a database error.
So the gate can never quietly disagree with the answers it came from, whatever any future code path does.
The alternative
A boolean the app sets whenever the onboarding answers change.
What it cost
The rule is no longer visible in the TypeScript, so you have to know to look at the schema. Worth it — an excluded user’s receipt never reaches the model, and that is not a guarantee I wanted resting on a code review.
Two ways to be turned away, two different screens
A health exclusion
Coeliac disease, pregnancy, eating-disorder history, kidney disease and four others.
Gets a clinical message pointing them to a professional. Their receipt never reaches the model.
Just not the audience
Not a UK adult with prediabetes. No health condition involved at all.
Gets a softer, non-clinical message. One shared screen would tell this group something untrue about their own health.
Surfaces
The screens people actually use
Seventeen routes, eleven behind the login. These four are where the time goes.

Receipts — grouped, filterable by retailer and date. 
Progress — only appears once there are two baskets to compare. 
Account — profile, inbound address, sign-out.
The decision inside each one
Five items, then expand — A basket can carry forty. Receipt detail shows five collapsible rows — forty open cards is not a screen anyone reads.
Say where the missing items went — Non-food items are stripped before scoring. Someone looking at 38 items and a score built from 31 needs to know what happened to the other seven.
No trend from one point — Progress stays empty until there are two baskets. A chart with one dot is worse than an honest "not yet".
The screen updates itself — Home listens for database changes, so a result landing while the tab is open just appears. Polling a pipeline measured in minutes buys a stale screen or wasted requests.
Revenue
Plans, payments and the paywall
One free basket check, then a monthly or yearly plan. Checkout, the billing portal, upgrades and cancellations all run through four Stripe edge functions — the secret key never touches the frontend bundle, and Stripe’s hosted checkout owns every payment screen.
Each function authenticates the user with the public key first and only then escalates to admin privileges. Doing it the other way round would mean trusting a user id sent from the browser.

Subscription — seven distinct states, including cancelled-with-time-remaining.
The one-line bug that switched the paywall off
The free tier also got its own flag rather than sharing the paid usage counter, which is contaminated in both directions: checkout zeroes it, so subscribing then cancelling would mint a fresh free credit, and a downgrading customer arrives carrying fifteen used receipts that look nothing like a spent free check.
Compliance
Data, privacy and the legal bits
Ten tables, three analytics systems, and a lot of things deliberately switched off.
The data model is small and boring on purpose: profiles carry identity, plan state, preferences and the eight health flags; receipts and per-item analysis carry the output; separate tables track swap-ladder position, usage windows, inbound email delivery, legal notice versions and cookie consent.
Disclaimer text lives in a table, not a component — and every receipt stamps which version applied when it was analysed, so updating a disclaimer cannot retroactively change what someone was actually shown.
Making a privacy leak a compile error
src/lib/analytics.ts
// The only properties any analytics event may ever carry.
interface AllowedProperties {
plan_selected?: string;
receipt_method?: string;
device_type?: string;
traffic_source?: string;
user_type?: string;
}
// trackEvent rebuilds the payload field by field instead of
// spreading whatever the caller passed in.A name, an email or a basket item is now a build failure rather than something a reviewer has to spot. Autocapture, session recording, heatmaps and automatic page views are all off — there is no acceptable version of replaying a screen full of someone’s health answers. Google Analytics is injected only after consent, so rejecting cookies produces zero Google requests.
Debugging
The bug that wasn’t the bug
Reported: users are getting a "speak to your doctor" email despite having no health conditions.
What everyone assumed
The safety gate is misfiring. It is the newest and most safety-critical logic in the product, and the symptom points straight at it.
What was actually happening
The gate was working perfectly and was completely blameless.
Two unrelated problems were stacked on top of each other, and neither one was in the gate.
Onboarding only saved if you picked a plan — The free-check button sat on the paywall, one step past the last question. Anyone who answered everything, saw the pricing and closed the tab saved nothing and received nothing — they looked, to the system, like someone who never finished onboarding.
One email served four different situations — Incomplete onboarding, not eligible, screening pending and genuine health exclusion all got the same clinical doctor-referral wording.
Outcome
What shipped
5
paid subscribers
first 14 days live
17
routes shipped
11 behind the login
4
Stripe edge functions
checkout, portal, upgrade, cancel
8
health exclusions enforced
checked before the model is ever called
2
gates the app cannot write
generated Postgres columns
11
analytics events
limited to 5 allowed properties
Retrospective
What I’d take to the next build
Empty is not expired — The biggest bug here came from treating a missing timestamp as an elapsed one. When one value stands in for two states, check whether they deserve separate handling.
Put the gate in the database — A generated column cannot drift from the data it derives from. An application check can, and eventually will.
A bug report names a symptom, not a cause — The doctor-referral emails were real and the safety gate was innocent. The failure was an onboarding step nobody finished and one message doing four jobs.
Enforce privacy in the type system — An allow-listed property interface stops a data leak at compile time. A convention stops it on a good day.

