Skip to main content
Back to Projects
Taxilos logo

Taxilos

Operations Platform

In short

Before
The taxi company's phone was its real line for orders; a missed call left no record, so the next day nobody knew who had called.
After
An AI assistant that speaks German answers the phone and opens the booking itself; the office manages bookings, drivers and vehicles from one panel, and each driver sees only their own trips.
What changed
Bookings from phone and web land in the same record, one call cannot open two bookings, and the price is read from a single tariff everywhere; the mobile app is still in planning.
Taxilos architecture diagram Taxilos Üç bileşen · tek rezervasyon kontratı · tek veritabanı Bileşenler bağımsız yayınlanır, ortak olan booking.v1 şeması ve tek Postgres şemasıdır. AI Çağrı Merkezi Python 3.13 · FastAPI / Granian Azure ACS · OpenAI · Speech · Cosmos AI Search · Container Apps kullanımda telefonu karşılar, rezervasyonu yazar Web Next.js 16 · Supabase Postgres Üç yüzey: site · yönetim · şoför portalı RLS + SECURITY DEFINER RPC yayında rezervasyon, operasyon, muhasebe Mobil Expo hedefleniyor Kapsam ve ekran akışı yazıldı Uygulama kodu henüz yok planlama faz 2 — sıraya alındı booking.v1 — platformdan bağımsız rezervasyon kontratı JSON Schema · durum ve hizmet tipi enum'ları · hata zarfı · idempotency anahtarı Python ve TypeScript aynı şemayı okur Supabase Postgres — tek şema 39 tablo · 43 migration · 70 RPC · 113 RLS politikası create_booking çağrı ve web aynı RPC'yi çağırır get_active_tariff tarife tek kaynak, bot 15 dk cache rol predicate'leri NULL-güvenli yetki kapısı Telefonla açılan rezervasyon ile web'den açılan rezervasyon aynı satırdır; ikinci bir senkronizasyon katmanı yoktur. Aynı çağrı iki kez işlenirse idempotency anahtarı ikinci kaydı engeller.
Three components ship independently: the live Python/Azure AI call centre, the live Next.js web application and the Expo mobile app still in planning. All three bind to the platform-agnostic booking.v1 contract and a single Supabase Postgres schema; a booking opened by phone and one opened on the web are the same row, with no second synchronisation layer.

A platform for a taxi company in Schöppingen, Germany: an AI assistant answers the phone, bookings and operations run in a web app, and a mobile app is in planning. Every booking, by phone or web, lands in the same system.

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.

Technologies Used

  • Next.js 16
  • TypeScript
  • Supabase Postgres
  • Python 3.13
  • FastAPI
  • Azure Communication Services
  • Azure OpenAI
  • Azure Speech
  • Azure Cosmos DB
  • Zod

Key Features

  • A German-speaking voice assistant that answers the phone and opens the booking itself
  • Phone and web write the same booking record — no synchronisation layer
  • Platform-independent booking.v1 contract: Python and TypeScript read the same schema
  • Three separate surfaces — public site and customer account, admin panel, driver portal
  • Tariff read from a single source; the bot carries no constant of its own
  • Content-claim guards — a page promising a feature that does not exist fails a test

Development Time

5 months (ongoing)

Problem

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.

Users

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.

My Role

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.

Architecture

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.

Key Decisions

  1. Two components write to one database — no integration layer

    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.

  2. The booking write derives its idempotency key from the call identity

    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.

  3. Tariff and pricing live in the database, not in code

    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.

  4. Claims made in site copy are protected by tests

    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.

  5. One language — German. Multilingual support was removed

    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.

Stack Rationale

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.

Security

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.

Performance

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.

Results

Active development
2026-04 → 2026-09 · web 160 commits · call centre 45 commits
TypeScript source files
451 (web)
Python source files
70 (call centre)
Test files
77 (web)
Schema
39 tables · 43 migrations · 70 RPCs · 113 RLS policies
Routes
79 pages — site and customer account, admin panel, driver portal
Component maturity
Call centre in use · web live · mobile in planning, no code written yet
Architectural guarantee
Phone and web call the same RPC; the idempotency key prevents the same call from producing a second booking
Architectural guarantee
Role predicates are NULL-safe — an account without a role cannot pass the admin RPCs

What I Would Do Differently

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.

Internal system

Let's find where to start

Tell me what you want to build; I'll tell you up front how long it takes and what the sensible first step is.