Works02 / 05
A wedding venue stopped juggling diaries and inboxes: couples book a visit on a phone, the confirmation comes from their manager’s own mailbox, and the booking lands in the venue’s system by itself.
blackbrookbarns.co.uk

- Client
- Blackbrook Barns, a wedding venue in Staffordshire, England
- Role
- Engineering: API, admin panel, widgets, automation
- Period
- May – October 2026
- Stack
- TypeScript, Fastify, Drizzle, PostgreSQL, Next.js, Preact, Make, Heroku, Vercel
Blackbrook Barns is an exclusive-use wedding barn between Lichfield and Sutton Coldfield, with a small sales team and a Webflow site that already did its job. Nobody needed a new website. They needed the work behind it to stop being manual.
The goal was specific: nobody should have to chase, copy details between systems or run two inboxes. Events stay in Events 500, the venue’s existing system. Payments stay out of scope.
That set the hard constraint: everything had to live inside somebody else’s page, on a site I did not control, and survive whatever that page did.
- 01
Visit booking on the venue’s own page
A 21-day agenda in Shadow DOM, so the site’s CSS and mine cannot touch each other. A weekly rota decides who hosts; the couple sees who they will meet, picks a time and gets a confirmation screen and an email. Works at phone width.
- 02
An API where a booking cannot fail for someone else’s reasons
Fastify, Drizzle and PostgreSQL. The row commits first; email and the venue’s system run after it and can only log a failure. A third party’s bad day never costs a couple their slot.
- 03
Email automation
A brochure request gets an instant thank-you, then about seven days later one nudge to book, skipped if the couple has already booked or enquired. The contact form gets a confirmation. Mail goes out from the responsible manager’s mailbox, and replies go back to that person.
- 04
One admin panel
Next.js: a three-year grid of wedding dates, the visit diary, each manager’s availability, the rota with an “everything to X” holiday switch, team accounts and failed notifications. A booked wedding closes that day to visits by itself.
- 05
Events 500, without retyping
Each booking appears in the venue’s system automatically, tagged with where it came from: website, chatbot or brochure email.
- 06
An AI assistant on the site
A chat assistant on the site answers questions about spaces, capacity, Twilight pricing and visits from a 13.9 KB knowledge base and points couples to the booking page.
How it fits together
Couple
- Venue’s Webflow page
- Booking widget40 KB, Shadow DOM
- Native formsbridged in by webhook
Core
- API36 paths
- PostgreSQLthe referee for every slot
After the commit
- Emailsigned webhook → Make → mailbox
- Events 500one-way push
Team
- Admin panel
- Rotawho owns which hours

The part that never makes it into a portfolio. All of it happened, and all of it is in the commit history.
Fault 01
The scroll bar that took five fixes
- Symptom
- On pages with a widget, the page got stuck. Couples could not scroll to the booking form or the footer: clamped at 1,470 of 2,636 pixels.
- Cause
- The site runs Lenis smooth scrolling, which caches the page height at start-up. A widget that grows later is invisible to it. Each fix found a deeper layer: a resize event sent too early, a poll that quit too soon, then the discovery that Lenis ignores resize altogether when the site pins the html height.
- Fix
- Lenis keeps its instance private, so the widget hooks its prototype for exactly one frame, captures the live instance as the run loop calls it, restores the method and calls resize() on every height change. It lives inside the bundle, because the footer scripts it once relied on later vanished from the site. You do not own the page you embed into.
Fault 02
Integrating with an API that barely exists
- Symptom
- The vendor’s PDF documents one endpoint, with its reference sections stripped out. So I probed the live service and wrote down what was actually there.
- Cause
expires_inis documented as seconds and returns an ISO timestamp. Creating an event takes no date. Reading events returns no identifiers. There is no update, no delete and no webhook.- Fix
- The token cache accepts both expiry shapes. Date, time and manager travel in a notes field so the record is useful to the team. The push never throws and is idempotent on the stored ID. Edit and Delete stay hidden in the admin, built and tested, because they could not reach the venue’s system and two diaries that disagree are worse than one missing button. When the cancellation check runs, it acts only on an unambiguous signal: wrongly cancelling a real appointment is far worse than catching a cancellation late.
Fault 03
Summer time, twice
- Symptom
- In May, in British Summer Time, the first availability tests failed: a Monday was classified as a Sunday. Then a code review found a worse one in the autumn clock change.
- Cause
- A slot is a London wall-clock time and the server runs in UTC. Midnight in London during BST is 23:00 the previous day, so the weekday came out wrong. And stepping 24-hour ticks across the 25-hour day on 25 October could repeat a date and lose a day from the booking window.
- Fix
- One timezone boundary in the code. Every day is anchored at noon London, which is never near a clock change, and weekdays are read from the date label, never from the instant. Three end-to-end tests pin the autumn change, the spring change and a plain winter week. A venue books months ahead, so a calendar has to be right on dates nobody is watching.
Fault 04
Letting the database be the referee
- Symptom
- Two couples can click one slot in the same moment; an admin can double-click Cancel. Review found the same class of bug elsewhere, caught before production.
- Cause
- Check-then-write leaves a gap. The strangest case: on refresh-token reuse the code revoked the token family and then threw, inside the same transaction, so the revocation rolled back with the error. The replay got a 401 and the family stayed valid.
- Fix
- A partial unique index on manager, date and time where the status is confirmed. Booking is a plain INSERT that catches the conflict and returns “slot taken”, tested with a real parallel race: exactly one winner. Cancelling is one conditional UPDATE, so only the winner fires the email. Admins can override policy, never the index: a conflict is not a policy.
Fault 05
One unsendable address could stop every email
- Symptom
- Couples should hear from their own manager, but the mail connection belongs to one person. The obvious change was to set From to that manager’s address.
- Cause
- An address the connection cannot send as fails the module, and a failed run can switch an instant scenario off. One new manager without the grant could silently stop every confirmation for everyone.
- Fix
- A separate sender on every payload: the manager’s address only if it is on an allow-list, otherwise the default, with Reply-To still the manager. Add a manager and nothing breaks. Retries recompute it from current config, so a stored payload cannot replay a revoked grant. Checked with a real send: no “on behalf of” line.
- automated tests in 62 filesCI log, 27 Oct 2026
- CI runs green since JuneGitHub Actions
- each widget, gzippedReact swapped for Preact at build time; the build fails over 50 KB
- database tables36 API paths, 47 operations
- days on the live booking agendavisible on the venue’s site
- days, first commit to the latest342 commits; built with Claude Code
Commit pulse
Every commit in the project’s repositories, on one line. The taller the bar, the hotter the stretch.
- Repo, requirements, six-phase roadmap
- CI, API, admin and widgets on production hosting
- Change request: a diary per manager
- Events 500 code, after probing the live API
- Events 500 push goes live
- Emails go out from the manager’s own mailbox
You do not have to rebuild a site that already sells. Small, well-tested pieces can live inside it, as long as every third party’s failure is designed in from day one. If your team still copies details between systems, that is the job I would start with.
Or ask the digital mind about it