Skip to content

Interested in AI, automation, blockchain, web and apps

Seoul, KR--:-- GMT
Let’s Talk

Work/App Development/KO

Phone-Based Japanese Restaurant Booking Service

Phone-Based Japanese Restaurant Booking Service

Booking Japanese restaurants by phone, on the traveler's behalf

Phone is the only booking channel

A lot of restaurants in Japan have no online booking channel, and phone booking works in Japanese only. This service solves that by having an agent call the restaurant and book the table on the traveler's behalf.

When I joined, v1 was already live. One NestJS + GraphQL + Prisma server, an Expo app, a Mantine admin, and an admin web, all sitting in separate repos (mimitable-server, mimitable-mobile-app, mimitable-admin, mimitable-admin-web), with the server exposing three separate GraphQL endpoints: apps/app, apps/admin, apps/caller. Changing one line of schema meant running codegen and opening PRs in three repos.

There were two requirements. Rebuild the app so that request, call, outcome, and refund all run on one state machine. And make sure every record an agent produces in Slack lands in the database.

System boundary and external dependencies
System boundary and external dependencies

Five repos merged, server deleted

I collapsed five repos into a single Turborepo + yarn v4 workspace

The app (Expo), the admin (Vite), the SEO web (Remix), the server, the crons, and the webhooks all read the same DB schema and the same enums. With the Drizzle schema in packages/db and BookingStatus in packages/enum shared as workspace packages, changing the schema produces type errors in all six apps at once. No codegen to run, no PRs to open in three repos. v1 was already serving real bookings, so I could not cut over in one shot. I kept apps/_admin-old around and stood the new admin up beside it while I moved things across.

I dropped GraphQL and went to tRPC + Drizzle

There are three clients and every one of them is TypeScript. There was no reason to keep a separate schema language and a codegen pipeline alive. Importing a single AppRouter type gives autocompletion in React Native and in a Remix loader alike. I picked Drizzle so that queries needing CASE WHEN and generated columns, like the availability calculation, could stay inside the ORM instead of escaping it. The whole router sits on one Lambda, so cold starts were not great. I settled for arm64 with a 60 second timeout.

Instead of a long running server, SST v3 (Pulumi) stamps out the Lambdas and the CloudFront Router

Traffic is flat all day and then piles up during the evening booking window. tRPC, image transforms, blurhash, webhooks, and crons are each their own Lambda with an sst.aws.Router in front. The stage name goes straight into the subdomain (trpc.{stage}.…), so every PR can get a complete environment of its own. There was nobody dedicated to infrastructure. Being able to write the IaC in TypeScript is what decided it.

Deployment and infrastructure
Deployment and infrastructure

Requests via queue, records via Slack

This piece covers two things only: how bookable times get calculated, and how a booking request reaches an agent. Payments and refunds, the image pipeline, and the public web pages each deserve their own write up, so I am leaving them out here.

A booking-assignment cron runs every minute, picks up bookings in the confirmed and cancel_requested_by_user states, creates one booking_assignment, and moves the booking to assigned inside the same transaction. Then it hits the webhook Lambda, which drops a card into the Slack booking channel. The message ts Slack hands back gets stored in booking_assignment.slack_thread_id, and that one column is the axis the whole ops tooling turns on.

Core data model
Core data model

Bookable times do not come from one place

It looks like you keep an opening hours table per restaurant, slice it into 30 minute chunks, and you have your slots. That is what v1 did and that is where I started too. The approach falls apart in three places. First, for a restaurant with Tabelog web booking open, the truth is whatever vacancy Tabelog knows about, not whatever was computed locally. Second, opening hours in Japan cross the day boundary, like 17:00 - 02:00 the next day. The moment you assume open_day === close_day, the small hours vanish entirely. Third, "closed this Wednesday only", "closed every Monday", and "closed for Obon" are three different kinds of exception, and none of them are expressible if you push them into the opening hours table.

Rather than picking one source of truth, I ranked them. restaurant.tabelog.availableDates and availableTimes build candidates in this order: (1) the Tabelog vacancy API, (2) restaurant_booking_availability, entered by hand, (3) restaurant_open_time. An empty result at one step falls through to the next, and if the Tabelog call throws, the exception is swallowed, a breadcrumb goes to Sentry, and the next source takes over.

The day boundary is normalized in normalizeOpenTime. (closeDay - openDay + 7) % 7 gives how many days the range spans; the first day runs from the opening time to 23:59, middle days are full days, and the last day runs from 00:00 to the closing time. Those pieces are grouped by weekday, converted to minutes, overlapping ranges merged, and zero length ranges thrown away.

Blocks always come off last. restaurant_block_time splits into three types, specific, weekdays, and holidays. specific is expanded into per date pieces by normalizeBlockedTimes, where all day blocks feed the date filter and partial blocks feed the time filter. holidays is matched against the ranges in the public_holiday table.

What matters here is that the code structure admits sources have different trust levels. If Tabelog is open, the local calculation is guaranteed to be wrong; if Tabelog is down, the local calculation is the only thing keeping the screen from going blank. Because the fallback is the normal path and not an error handler, something is always painted on the calendar even when an external dependency wobbles.

And because blocking is separate from candidate generation, when a request like "block this restaurant for all of next week" comes in, it comes out the same way whether the source was Tabelog or opening hours. Keeping all three kinds of block in one table split by a type column was deliberate too: when a request like "block every restaurant over new year" eventually arrives, only holidays has to grow.

Speaker separation solved on the line, not in the model

Telling "this is the caller, this is the restaurant staff" apart in one mixed down track is not what Whisper does.

I had Twilio record in dual channel. The agent's voice and the restaurant's voice are stored on separate left and right channels from the start.

Because speaker separation is settled at the infrastructure level and not by AI, accuracy does not move with audio quality or people talking over each other.

What a user can do

Sign up with a social account and verify your phone number
Sign up with a social account and verify your phone number

Pick a city on Home and read other people's review feed
Pick a city on Home and read other people's review feed

Find a restaurant by keyword and filters, then check it on the map
Find a restaurant by keyword and filters, then check it on the map

Open a restaurant page from a shared link or from the app
Open a restaurant page from a shared link or from the app

Pick a first and second choice date, time, and party size
Pick a first and second choice date, time, and party size

Accept the terms, pay the deposit, and submit the booking request
Accept the terms, pay the deposit, and submit the booking request

An agent takes the queue and calls the Japanese restaurant
An agent takes the queue and calls the Japanese restaurant

Cancel a booking and see what actually comes back
Cancel a booking and see what actually comes back

Review the restaurant you visited and get the deposit back as points
Review the restaurant you visited and get the deposit back as points

Save a restaurant to a wishlist and manage your lists
Save a restaurant to a wishlist and manage your lists

Invite a friend, collect the points, and spend them on a booking
Invite a friend, collect the points, and spend them on a booking

Pick your trip city, dates, and party size, then save the itinerary
Pick your trip city, dates, and party size, then save the itinerary

Sort out your account, notifications, and language from the Menu tab
Sort out your account, notifications, and language from the Menu tab

Report a missing restaurant or flag information that is wrong
Report a missing restaurant or flag information that is wrong

An operator adds a restaurant from a single Tabelog URL
An operator adds a restaurant from a single Tabelog URL

1 / 1

Quietly wrong is the worst failure

I did not get everything into a nice state. Starting with the part that hurts most.

The Tabelog scraping hangs off a hardcoded cookie and User-Agent. If the markup changes, the cheerio selectors return empty strings, those empty strings go straight into the LLM, and plausible looking values get filled in. Being quietly wrong is the worst failure mode there is. At minimum it needed a guard that throws when a required field comes back empty.

Read next

Multi-Country Ultrasound Delivery Service and Console

MommyTalk — 2022