O problema
As reservas chegam por telefone, Instagram e WhatsApp, e vão parar a um caderno. Ninguém sabe quantas pessoas vêm às 20h30, duas mesas ficam prometidas à mesma família e o cliente nunca sabe se foi confirmado. O sistema faz três coisas: recebe o pedido sem ninguém atender, mostra o serviço inteiro num plano de sala e diz ao cliente em que estado está a reserva dele, sem lhe ligar.
Modelo de dados
Uma reserva é uma linha: quem, quantos, que dia, que hora, que estado e que mesa. As mesas físicas já existiam da demo de pedidos e são partilhadas. A sala de demonstração continua a ser a mesma, com a limpeza automática ao fim de seis horas.
| Tabela | Para que serve |
|---|---|
| demo_reservations | Uma linha por reserva: quem, quantos, dia, hora, estado, mesa e os carimbos de tempo de cada mudança. |
| demo_venue_tables | As mesas físicas e os lugares de cada uma. Partilhadas com a demo de pedidos. |
| demo_rooms | A sala de demonstração que liga os dispositivos. Apagada ao fim de seis horas, e com ela as reservas. |
Lotação no servidor
O pedido de reserva é uma função no Postgres, não um insert directo. É ela que soma os lugares já prometidos naquele horário, compara com a lotação da casa e recusa antes de guardar. Se dois clientes pedirem ao mesmo tempo, o servidor decide, não o browser. O código de reserva também nasce aí, curto e sem letras que se confundem ao telefone.
-- O pedido é uma função: a lotação decide-se no servidor.
-- Lock por sala, dia e hora: dois pedidos ao mesmo tempo
-- entram um de cada vez, nunca passam os dois.
perform pg_advisory_xact_lock(
hashtext(p_room || '|' || p_day::text || '|' || p_slot::text));
select coalesce(sum(party), 0) into v_booked
from public.demo_reservations
where room_code = p_room and day = p_day and slot = p_slot
and status in ('pendente', 'confirmada', 'sentada');
select coalesce(sum(seats), 0) into v_capacity
from public.demo_venue_tables;
if v_booked + p_party > v_capacity then
raise exception 'sem lugar';
end if;
-- Código curto, legível ao telefone: sem O/0 nem I/1
v_code := 'R' || upper(translate(
substr(md5(random()::text || clock_timestamp()::text), 1, 5),
'01', 'XY'));Uma mesa, uma reserva
A regra 'nenhuma mesa pode ter duas reservas activas no mesmo horário' não vive no código da aplicação: é um índice único parcial. Só conta reservas confirmadas ou sentadas, por isso uma falta ou um cancelamento liberta a mesa sem ninguém ter de a desatribuir. Se alguém tentar, o Postgres recusa e o ecrã mostra o aviso.
-- Uma mesa não pode ter duas reservas activas à mesma hora.
-- Índice parcial: uma falta ou um cancelamento liberta a mesa
-- sem ninguém ter de a desatribuir.
create unique index demo_reservations_table_slot_idx
on public.demo_reservations (room_code, day, slot, table_id)
where table_id is not null
and status in ('confirmada', 'sentada');
-- A chave pública não lê nome, telemóvel nem nota da tabela:
-- só saem pela função que exige o código da sala.
grant select (id, room_code, code, party, day, slot, status, table_id, ...)
on public.demo_reservations to anon;
-- Carimbos de tempo automáticos a cada mudança de estado
create trigger demo_reservations_stamp
before update on public.demo_reservations
for each row execute function public.demo_stamp_reservation();Tempo real nos dois lados
O cliente fica a ouvir a reserva dele; o restaurante ouve o serviço inteiro. A mesma subscrição alimenta os dois ecrãs, com polling de oito segundos como rede de segurança para redes que bloqueiam websockets.
const channel = supabase
.channel(`demo-reservations-${room}`)
.on("postgres_changes",
{ event: "*", schema: "public", table: "demo_reservations",
filter: `room_code=eq.${room}` },
() => void load(room))
.subscribe((status) => {
if (status === "SUBSCRIBED") void load(room);
});
// Rede de segurança para redes que bloqueiam websockets
const poll = window.setInterval(() => void load(room), 8000);Do lado do cliente
Três decisões e um formulário curto: pessoas, dia, hora. As horas cheias aparecem riscadas antes de escolher, calculadas a partir das reservas que já existem, para não haver surpresas depois de submeter. O estado muda debaixo dos olhos do cliente, sem recarregar.
// As horas cheias aparecem riscadas antes de escolher
const full = bookedAt(day, slot) + party > capacity;
// Do lado do restaurante, atribuir mesa a um pedido confirma-o
async function assign(r: Reservation, tableId: number | null) {
const changes: Partial<Reservation> = { table_id: tableId };
if (tableId && r.status === "pendente") changes.status = "confirmada";
await patch(r.id, changes);
}O que faria diferente num cliente real
Confirmação por SMS ou WhatsApp com o código, um lembrete no dia anterior e um link para cancelar. Duração por reserva em vez de horário fixo, para libertar a mesa a tempo. Regras de lotação por zona, e não só pela casa toda. E autenticação: hoje qualquer visitante muda estados porque é uma demo; num restaurante, só a equipa.