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.
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.
| Table | What it holds |
|---|---|
| demo_rooms | The room shared across devices. Deletes itself after 6 hours. |
| demo_menu_categories | Starters, mains, desserts, drinks. Read-only. |
| demo_menu_items | Dishes with price and description in Portuguese and English. |
| demo_venue_tables | The dining room tables, with seats. |
| demo_orders | One order: table, status, note, total and time stamps. |
| demo_order_items | The order lines, with name and price frozen at the time. |
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.
-- 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');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.
-- 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);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.
-- 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;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.
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);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.
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.