
Operations Platform
A three-component platform for a taxi company in Schöppingen, Germany: an AI call centre that answers the phone, a web application that runs bookings and operations, and a mobile app still in planning. The three components are at different maturity levels; what they share is one booking contract and one database.
Tariff values and phone numbers are deliberately absent from this case study: their counterparts in the repository are unapproved placeholder values, and treating them as real would spread wrong information.
The phone is the real demand line for this business. A missed call is not only lost work; because there is no record of it, nobody knows the next day who called. The hard part of a taxi call centre, though, is not understanding speech: it is making the booking that comes out of the conversation the same record as the booking opened on the web. If two systems write to two databases, a synchronisation problem appears, and the place where that problem fails to be solved is always the same — two cars sent to the same slot. The second issue is legal: in Germany, a published site that promises a feature which does not exist (a mobile app, live vehicle tracking) is an unfair-competition item. That kind of error does not blow up like a code bug — the build passes, the tests are green, only the page lies.
Four parties, four different constraints. The caller: may be elderly, does not install apps, gives the address by speaking, and expects the other side to speak natural German. The web customer: wants to repeat a past ride from their own account and manage an address book. The office: sees bookings, drivers, vehicles, shifts, recurring rides, corporate invoicing, medical-transport paperwork and workshop records in one panel. The driver: looks from a phone, should see only their own shift and their own rides — and nothing else in the panel.
Single developer, three repositories. Drawing the system boundaries, the booking contract shared by two components, the Postgres schema and authorisation model, adapting the call-centre side to the business (vehicle types, service types, tariff reads, booking writes), the three surfaces of the web application, and deployment.
The three components ship independently and share exactly two things: the `booking.v1` JSON Schema contract and one Supabase Postgres schema. The call centre runs on Python 3.13 with FastAPI/Granian; the telephone line, speech recognition and synthesis, model calls and call state live on the Azure side (Communication Services, Speech, OpenAI, Cosmos DB, AI Search, Container Apps). During the conversation the bot accumulates what it learns in a "claim" object; when closing the booking it converts that claim into a `booking.v1` body and writes it through Supabase's `create_booking` RPC. The web side is Next.js 16 App Router; the write path is Server Action → Zod → RPC, the read path Server Component → query layer. Authorisation sits at three levels: route protection in `proxy.ts`, RLS policies, and role gates inside SECURITY DEFINER functions. The application is split into three surfaces — public site and customer account, admin panel, driver portal — but they run on one database and one role model. The tariff is not a constant in any component: it is read from the `get_active_tariff` RPC, the bot keeps it in a fifteen-minute cache, and falls back to the last good value if the read fails.
The alternative was for the call centre to keep its own record and hand it to the web over a queue or webhook. In that design the booking exists in two places and the answer to "which one is right" is left to a reconciliation rule. Instead the call centre writes directly into the same schema, through the same RPC the web uses. A booking opened by phone and one opened from the site are the same row; in the office panel the only thing separating them is the source field.
Trade-offThe Python side is now coupled to the database schema: if the RPC signature changes the call centre has to change too, and that coupling is not caught by a compiler — it surfaces at runtime. The call centre also carries a service-role key, that is, a key which bypasses RLS, held in its own infrastructure. The contract file reduces this risk but does not remove it.
In a voice flow the "confirm" step is not a single event: the model can call the tool again, a connection can drop while the call continues, an operator can reprocess the same call. Because the key is derived from the call identity, a second write attempt from the same call does not produce a new booking.
Trade-offSince the key is unique per call, deliberately opening a second booking within the same call — when the customer says "add one for the return trip too" — is not directly possible and has to be handled separately. The guarantee protects the single write path, not the business rule.
Price shows up in three places: the estimate the bot says out loud, the pricing page on the site, and the invoice the office issues. If those three diverge the error reaches the customer and cannot be taken back. The tariff is kept in one table and all three surfaces read the same RPC. On the bot side there is a fifteen-minute cache; when a read fails it continues with the last good value rather than silently dropping to zero.
Trade-offA tariff change made in the admin panel does not reach a live call immediately — until the cache turns over, the bot quotes the old value. And "fall back to the last good value" means that if the database is unreachable for a long time, the bot will confidently quote a stale price. That is a deliberate acceptance.
Promising a feature that does not exist is an unfair-competition item in Germany, and this error trips no technical alarm. Today there is neither a mobile app nor live location tracking; a sentence describing either of them fails a test if it enters the page. The same guard catches a counting error too: the heading spells the number of items as a word, so adding a new service made the heading quietly wrong.
Trade-offThe expected values are written into the test by hand; derived from the source module the test would be a tautology and would drift silently along with the content. The cost is that a legitimate content change also breaks the test — every copy change requires updating it manually.
In a voice assistant, language support is not a matter of translation files: each language means a separate voice, separate pronunciation hints, a separate place-name vocabulary and a separate test burden. Because the service area is monolingual, the Turkish and English paths were removed; both the bot and the site stayed in one language.
Trade-offA customer who does not speak German cannot be served by phone today; there is no path other than handing them to a human. If a language is added back, not only the copy but the voice and pronunciation hints have to be rebuilt.
Python on the call-centre side is not a language preference but an ecosystem one: the telephone line, streaming speech recognition and synthesis, model calls and call-state persistence all arrive as ready pieces of the same cloud provider, and the mature clients for those pieces are on the Python side. The choice of region and deployment type is legal rather than technical: it is set up so that data stays in the German region and the model deployment does not spill outside it. On the web side, Next.js 16 App Router keeps three surfaces in one codebase while leaving authorisation on the server. Supabase was chosen not for its ready-made interface but because RLS and SECURITY DEFINER functions can be used together: the authorisation rule sits next to the data rather than in the application, and the Python side is subject to the same rule. Zod acts as a single schema for both form input and Server Action input, while `booking.v1` is the shared contract between two languages — read as a type on the TypeScript side and as validation on the Python side.
Authorisation has three levels: route protection, RLS policies, and role gates inside SECURITY DEFINER functions. The schema carries 39 tables, 43 migrations, 70 RPCs and 113 RLS policies. The most expensive lesson in that layer came from measurement: the role-reading function returned NULL for an account with no role, and because the role predicates wrapped that NULL in a plain comparison, all three of them produced NULL. In plpgsql the condition `IF NOT (NULL)` is NULL and the branch never runs — meaning the authorisation gate of 45 admin RPCs was being passed without any escalation. Reachability was not theoretical either: the one class of user left without a role was a provider account that had not completed onboarding. The predicates were made NULL-safe; RLS behaviour did not change, because USING and WITH CHECK only let TRUE through. The second finding was in the driver portal: the field binding a driver record to the session user was written nowhere in the repository — the portal would have run empty in production. The service-role key is read only on the server; the deployment path additionally verifies its presence, and a drift check runs against the persona text falling outside version control.
The admin and driver surfaces of this platform are closed; because there is no stored, date-stamped Lighthouse output, no page-speed score is written here. The measurable side is the call path: in a voice flow, latency is heard inside the conversation itself, so the steps that run mid-conversation — price and availability lookups — are cached: the tariff for fifteen minutes, distance for the duration of the call. The module-level cache is locked and double-checked, so two simultaneous calls do not produce more than one query. On the web side, the pricing page is refreshed hourly through ISR, while the admin and driver surfaces are fully dynamic.
I would not spread the authorisation gate across plpgsql functions the way it is spread today. Because the rule is repeated in 45 separate places, a single predicate falling into three-valued logic opened all the gates at once; had the rule been defined in one place and in a form that cannot produce NULL, this class of error would have been impossible. The same lesson appeared in two other projects in this portfolio: in Bergaz Operations the read-only guarantee sits in an in-application query shield, and every new query path that bypasses the shield punctures it; in Bergaz Food I first built payment idempotency in the application layer and then had to move it into a database constraint. The recurring conclusion is that the closer a guarantee is to the data, the harder it is to bypass. The second point is measurement: neither of the two unplanned findings was on the task list — both came out of querying the live system. Asking "what does an unauthorised call return" rather than assuming a gate works found two holes that none of the tests I wrote in this project caught. The third is mobile: when you run a three-component platform alone, the third component always falls to the end of the queue. Today I would write a narrower scope for mobile from the start — only the driver side rather than a full application — because a narrow scope can enter the queue and a broad one cannot.
Tell me what you want to build; I'll tell you up front how long it takes and where to start.