The problem
Temperatures get written by hand on a sheet that lives on top of the fridge. When the inspection turns up, half of it is missing, and the other half was filled in all at once on Friday afternoon, with round numbers and the same pen. That is not a record, it is a badly made alibi. And when a fridge genuinely fails, nobody notices until the fish smells.
Data model
The control points are the house's HACCP plan: what gets measured, where, between which limits and on which shifts. Reference data, the same for everyone. The records belong to a room and carry the day, the shift, the reading, the verdict and the name of whoever signed. A unique index makes two records of the same point on the same shift of the same day impossible.
| Table | What it holds |
|---|---|
| demo_haccp_points | The house's HACCP plan: what gets measured, where, between which limits and on which shifts. Reference data, the same for every room. |
| demo_haccp_checks | One record per point, shift and day: the reading, the server's verdict, who signed, and if it failed, the corrective action and who signed that. |
| demo_haccp_deliveries | Each delivery: supplier, product, batch, use-by date, quantity and arrival temperature, with what the server found and what the person decided. |
| demo_rooms | The demo room that links the devices. Deleted after six hours, and the records with it. |
The verdict is born on the server
This is the decision that makes the system worth anything. The phone sends the reading and the point, never the pass or fail. The function fetches that point's limits, compares, and writes the result. Whoever fills it in cannot say everything is fine: they can only say the number they read. A cold point arriving with no reading, or with an impossible value, is refused before anything is stored.
-- O telemóvel envia a leitura e o ponto. Nunca o veredicto.
select * into v_point from demo_haccp_points where id = p_point;
if v_point.kind = 'tarefa' then
v_ok := true;
p_value := null;
else
if p_value is null then raise exception 'sem leitura'; end if;
if p_value < -40 or p_value > 200 then
raise exception 'leitura impossivel';
end if;
-- O cumpre ou não cumpre nasce aqui, e só aqui
v_ok := p_value >= v_point.min_c and p_value <= v_point.max_c;
end if;
-- Hoje no fuso do restaurante, não no do servidor
v_today := (now() at time zone 'Europe/Lisbon')::date;A breach does not close itself
A reading outside the limits stays open until somebody writes what they did and signs it. The function refuses an empty action, and refuses an action on a record that passed, so the history does not fill with noise. Fixing a mistyped reading replaces it and reopens the breach, instead of leaving two versions of the same shift arguing with each other.
-- Um incumprimento só fecha com uma acção escrita e assinada.
if length(btrim(coalesce(p_action, ''))) < 4 then
raise exception 'accao vazia';
end if;
-- E não se "corrige" o que cumpre: isso seria ruído no registo
if v_row.ok then raise exception 'este registo cumpre'; end if;
-- Corrigir uma leitura mal metida substitui-a e reabre o
-- incumprimento, em vez de deixar duas versões do mesmo turno
on conflict (room_code, point_id, day, shift) do update
set value_c = excluded.value_c, ok = excluded.ok,
signed_by = excluded.signed_by, created_at = now(),
corrective = null, corrected_by = null, corrected_at = null;Who signed is not public
The name of whoever logged it, the note and the corrective action are people's data: they do not come out through the public key. The key that ships in the browser reads the point, the time, the reading and whether it passed, which is what the screen needs to light the alert in real time. The rest comes out through a function that requires the room code.
-- Um ponto só se regista uma vez por turno e por dia.
create unique index demo_haccp_checks_once_idx
on demo_haccp_checks (room_code, point_id, day, shift);
-- Um ponto de temperatura sem limites não é um ponto de controlo
constraint demo_haccp_limits_needed
check (kind = 'tarefa'
or (min_c is not null and max_c is not null
and min_c <= max_c));
-- Quem assinou não sai pela chave pública: chega o que o ecrã
-- precisa para acender o alerta em tempo real.
grant select (id, room_code, point_id, day, shift, value_c, ok,
corrected_at, created_at)
on demo_haccp_checks to anon;// O ecrã do responsável acende sozinho quando a equipa
// regista uma leitura fora dos limites, do outro lado da casa.
const channel = supabase
.channel(`demo-haccp-${room}`)
.on("postgres_changes",
{ event: "*", schema: "public", table: "demo_haccp_checks",
filter: `room_code=eq.${room}` },
() => void load(room))
.subscribe();
// Incumprimentos por resolver: fora dos limites e sem acção escrita
const openFails = checks.filter((c) => !c.ok && !c.corrected_at);Verifying is not the same as correcting
The cycle does not close on the corrective action. It closes when somebody else checks the action was actually done and signs. The function refuses to verify what has no action yet, and a table constraint enforces the same even if someone writes straight to the database. If the reading is corrected, both the action and the verification fall away: they refer to a number that is no longer there. In this demo any visitor signs both parts; in a restaurant, verifying belongs to the manager, not to whoever logged it.
-- Não se verifica o que ainda não foi corrigido.
-- Na função, para dar um erro legível:
if v_row.corrected_at is null then
raise exception 'ainda sem accao correctiva';
end if;
-- E na tabela, para valer mesmo que alguém escreva direto:
alter table demo_haccp_checks
add constraint demo_haccp_verify_order
check (verified_at is null or corrected_at is not null);
-- Uma leitura nova apaga a acção e a verificação: referem-se
-- a um número que já não está lá.
on conflict (room_code, point_id, day, shift) do update
set value_c = excluded.value_c, ok = excluded.ok,
corrective = null, corrected_by = null, corrected_at = null,
verified_by = null, verified_at = null;Traceability starts at the delivery door
Supplier, product, batch, use-by date and temperature on arrival. This is what lets a kitchen answer when a supplier recalls a batch. The receiving limits live on the server: chilled up to 4 degrees, frozen up to -18. The server says whether it was in range; accepting or rejecting stays the decision of whoever is at the door, and is recorded as such. Accepting an out-of-range delivery is a legitimate call in some cases, and it gets written down as a decision rather than hidden. Rejecting without writing why is refused by a constraint.
-- Os limites de recepção são do servidor, não do telemóvel.
v_max := case p_kind when 'refrigerado' then 4.0
when 'congelado' then -15.0
else null end;
v_temp_ok := p_temp <= v_max;
v_date_ok := p_expiry >= v_today;
-- Repare no que NÃO acontece aqui: o servidor não decide aceitar.
-- Diz se estava dentro; aceitar é de quem está na doca, e fica
-- registado como decisão dessa pessoa.
-- Recusar sem dizer porquê não é recusar, é esconder:
constraint demo_delivery_reason_needed
check (accepted
or (reject_reason is not null
and length(btrim(reject_reason)) >= 4));What this software does not do
It does not invent critical limits or validate a HACCP plan. The control points, the limits and the frequencies are configuration, written from the house's own plan, which is the responsibility of someone qualified. The software documents, measures against what is configured, and keeps the proof. Telling a client that software makes them compliant would be a lie, and a client who has been through an inspection spots it immediately.
What I would do differently for a real client
Probes logging automatically in the fridges, so the number does not depend on somebody remembering. A phone alert when a point goes out of range after hours. A photo attached to the goods-received record. A monthly PDF export to hand the inspector without printing anything by hand. And real authentication: here any visitor signs with whatever name they like because it is a demo; in a kitchen, each person logs in with their own code.