Skip to content
PMPaulo Mota
Back to the demo

How it was built

KDS, from the inside

The data model, the security policies, what runs in realtime, and the decisions I made, including the ones I would change.

01

The problem

In a full dining room the order goes from memory to paper and from paper to the kitchen. Time is lost, dishes are lost, and nobody knows how long table 7 has been waiting. The system fixes three things: the order arrives with no middleman, the kitchen sees elapsed time, and the customer knows the status.

02

Data model

Six tables. The catalogue is shared and read-only; orders belong to a room, which is what links the phone to the kitchen screen in the same demo. For a paying client, the room is the restaurant.

TableWhat it holds
demo_roomsThe room shared across devices. Deletes itself after 6 hours.
demo_menu_categoriesStarters, mains, desserts, drinks. Read-only.
demo_menu_itemsDishes with price and description in Portuguese and English.
demo_venue_tablesThe dining room tables, with seats.
demo_ordersOne order: table, status, note, total and time stamps.
demo_order_itemsThe order lines, with name and price frozen at the time.
03

Row level security

Postgres decides what each request may do, not the site's code. The key shipped to the browser is publishable: public by design, and able to do only what the policies allow.

sql
-- Catálogo: só leitura
create policy demo_menu_items_read on public.demo_menu_items
  for select to anon, authenticated using (true);

-- Salas: criar, com o formato validado no próprio Postgres
create policy demo_rooms_insert on public.demo_rooms
  for insert to anon, authenticated
  with check (code ~ '^[A-Z0-9]{4,8}$');

-- Pedidos: criar e mudar de estado. Nunca apagar,
-- e só enquanto a sala ainda é recente.
create policy demo_orders_update on public.demo_orders
  for update to anon, authenticated
  using (created_at > now() - interval '6 hours')
  with check (created_at > now() - interval '6 hours');
04

The returning trap

An insert with returning also needs a select policy, because the returned row is a read. The leads table has no select policy, so the form writes without asking for anything back. I found this by testing as the anon role, not by reading documentation.

sql
-- Contactos: escrever sim, ler não.
-- Sem política de select, ninguém lê as leads com a chave pública.
alter table public.leads enable row level security;

create policy leads_insert on public.leads
  for insert to anon, authenticated
  with check (true);
05

One transaction, not two

The first version inserted the order and then its lines. The realtime event arrived between the two and the kitchen saw an empty ticket for a moment. Now everything goes in through one function, and the total is computed on the server from the real menu prices, not from what the browser claims.

sql
-- O pedido e as suas linhas na mesma transacção
insert into public.demo_orders (room_code, table_id, note, total_cents)
values (p_room, p_table, nullif(btrim(coalesce(p_note, '')), ''), 0)
returning id into v_order_id;

insert into public.demo_order_items
  (order_id, menu_item_id, name_snapshot, qty, unit_price_cents)
select v_order_id, m.id,
       case when line->>'locale' = 'en' then m.name_en else m.name_pt end,
       least(greatest((line->>'qty')::smallint, 1::smallint), 20::smallint),
       m.price_cents                    -- o preço vem da ementa, não do browser
from jsonb_array_elements(p_items) as line
join public.demo_menu_items m on m.id = (line->>'id')::integer;
06

Realtime, and kitchen wifi

The kitchen screen subscribes to changes in its room and updates without a refresh. Behind it, a read every eight seconds. Realtime is what makes it instant; the interval is what stops the screen going blank in a kitchen whose network blocks websockets, which happens more than you would think.

typescript
const channel = supabase
  .channel(`demo-orders-${room}`)
  .on("postgres_changes",
      { event: "*", schema: "public", table: "demo_orders",
        filter: `room_code=eq.${room}` },
      () => void loadOrders(room))
  .subscribe((status) => {
    if (status === "SUBSCRIBED") void loadOrders(room);
  });

// Rede de segurança: se a cozinha bloquear websockets, o ecrã
// continua a encher, só que de oito em oito segundos.
const poll = window.setInterval(() => void loadOrders(room), 8000);
07

What changes for a paying client

This demo's policies are permissive on purpose: it is a public demo with invented data and anyone has to be able to try it without signing up. In a paying restaurant, every row is tied to that restaurant and to staff accounts, and nobody sees another venue's orders.

08

What I would do differently

At higher volume, order history would move out of the active orders table, because the kitchen screen only ever wants today. And I would add a local queue on the waiter's phone, so an order is not lost when the wifi drops between the terrace and the kitchen.