Item 5 of the MVP roadmap shipped on 2026-08-06: the app is live at
gate-watch.possebom.com and the first real plate landed at 12:01 BRT.
Records what the deploy taught, so a future session does not relearn it:
the central Postgres is reached as postgres:5432 (DNS alias on
traefik_proxy) and not as a LAN IP; the Protect webhook must use the LAN
address because the public host answers 403 by design; cameras
auto-register with the device id as location_name.
Also documents why plate_unifi.rs models only the fields the handler
reads — the production rejection that motivated it.
A real Alarm Manager POST was rejected in production with
`missing field "group"`. The payload model required nine fields the
handler never reads (conditions, sources, name, eventPath,
eventLocalLink, eventId, group, key, zones), so any absent one made
serde reject the whole JSON and the plate was lost. Model only what is
read — device, timestamp, value, thumbnail, snapshot — and document why
the struct must stay lean. Regression test uses the payload shape
observed on 2026-08-06.
Stop logging the database URL in init_db: it printed the password in
clear text, and startup already logs it via redacted_database_url().
The kauai .env pointed the database at 10.2.0.200, which is no host —
the container crashlooped on first deploy with "No route to host". Use
the postgres:5432 DNS alias on traefik_proxy, like the other stacks.
compose.yaml follows the house convention (traefik_proxy, le resolver,
crowdsec@file, diun); the Protect webhook enters via LAN port 8084 (the
internal-IP guard rejects public client IPs). kauai-setup.sh creates the
gate_watch DB on the central Postgres, writes the .env (prompted token,
generated JWT/DB password), wires the 911100 PUT/DELETE carve-out into the
crowdsec appsec configs (anchored sed, verified against the live compose in
a /tmp sandbox) and brings the stack up with a health smoke test.
An ownerless plate with >=3 passages in the last 7 days fires one Telegram
alert to every user with a telegram_id, then goes silent for 7 days (or until
the vehicle gains an owner). The silence window is an atomic conditional
UPSERT on the new recurring_unknown_alerts table, so concurrent ingests can't
double-alert; verified the claim semantics and the full migration chain on a
throwaway postgres:16. Runtime queries only (no .sqlx entries). Falls back to
a text message when the passage has no photo (new TelegramService::send_message).
Auto-registered Protect cameras now get protocol='unifi' (new CHECK value)
instead of a bogus vzenith/cancela row; the camera form offers UniFi Protect
and skips credential requirements for it (webhook-only source). The alarm
image falls back to alarm.snapshot when thumbnail is absent, stripping any
data-URI prefix. Detection carries the source protocol. 3 unit tests cover
the Alarm Manager payload contract.
SummaryDto becomes today_count / distinct_plates_today / known_today /
unknown_today (runtime queries, no .sqlx entries needed). The parking-spots
setting, its API, page and repository are gone — /settings now redirects to
the profile (only Aparência/Perfil remain).
CONTEXT.md gets the monitor glossary (Known Vehicle, Alert, Recurring
Unknown; Gate and Whitelist removed). CLAUDE.md records the fork decisions,
the UniFi webhook contract, inherited gotchas and the agreed MVP roadmap.
Package/lib/binary renamed (gate_watch crate), SPA branding + storage keys,
docker-compose services/db renamed, Dockerfile drops the lpr-whitelist git
credential. CI keeps build+push (image name follows the repo) and the Telegram
notify, but the prefecture deploy webhook is gone. Prefecture-specific deploy/
configs and historical docs/ removed (git history keeps them).
Locations lose open/close-gate and resync (the API is gone); the camera form
drops gate_type/relay-channel. Vehicle create/assign no longer polls
whitelist-status — plain success toasts. The is_whitelisted badge is
relabeled Conhecido/Desconhecido (known owner, not authorization).
gate-watch is a street monitor: it observes plates, it does not act on any
barrier. Deleted the gate API, camera_driver (Vzenith/Dahua gate+whitelist
ops), whitelist queue worker + daily reconcile, and the dead Askama templates.
digest_get moved to utils/digest_http (the Dahua event stream still needs it).
New migration drops whitelist_queue{,_execution} and the enqueue trigger.
Drivers/companies stay as known-owner metadata (enrichment, not authorization).
A plate typed in lowercase went straight from the form to the INSERT,
where the check_plate_format constraint (which only accepts [A-Z])
rejected it with 23514. The handler only maps 23505, so it surfaced as
a 500 "Erro ao acessar banco de dados" and nobody could register a
vehicle from the UI.
Run the input through Plate::parse, the same value type the webhook,
import and reconcile paths already use: it uppercases, strips
separators and validates both Brazilian layouts. A malformed plate is
now a 400 naming the expected format instead of a database error.
The form field uppercases while typing and drops non-alphanumerics, so
what is shown is what gets stored.
FontProvider builds the class as `font-${font}`, which Tailwind's
scanner cannot see, so the family utilities were only ever emitted by
accident: `font-plex` and `font-manrope` appear as literal strings in a
doc comment in config/fonts.ts (and font-manrope in one className), and
`font-system` appeared nowhere, so its utility did not exist at all.
Selecting "System" therefore applied a dead class. The rendered result
happened to be correct because Tailwind's preflight already sets
`html { font-family: var(--default-font-family) }`, which resolves to
the system stack, but nothing about that was intentional: defining
--default-font-family or a family on body would have broken the option
silently.
Add the missing --font-system token and an explicit @source inline
safelist next to it. Verified by mutation: removing `font-plex` from
the doc comment and rebuilding still emits all three utilities, so the
safelist is now what generates them rather than the comment.
No visible change; this makes the existing behaviour intentional and
stops a font rename from silently dropping the family.
vehicle-detail seeded the owner toggle from the loaded vehicle inside
an effect, which cost an extra committed render and left the dependency
list out of sync with the body: it read `vehicle` but listed
`vehicle?.company_id` / `vehicle?.driver_id`. Adjust the state during
render against a key built from those two ids instead, which also
clears the exhaustive-deps warning the same effect carried.
Verified equivalent by simulating both versions across eight event
sequences (initial load, user flipping the toggle, assigning a company,
removing the owner, swapping the driver, and no vehicle at all):
identical committed state in every one.
alert-dialog keeps its effect behind a disable comment, with the reason
recorded next to it. That dialog stays mounted between openings because
the parent always renders it with `open` as a prop, so the canonical
`key` remount would not reset the "New -> close -> New" case, and keying
on `open` would remount on close and kill the exit animation.
Lint goes from 5 problems (2 errors) to 2 (0 errors); the remaining two
are pre-existing react-refresh warnings in accent-color-provider.
Inter is visually ubiquitous; IBM Plex Sans is drawn for technical
interfaces, gives the dense tables more character, and keeps full
Latin-Extended coverage for pt-BR.
Rename the font token to 'plex'. fonts[0] is both the default and the
fallback for an unrecognised cookie, so anyone still holding
font=inter lands on the new face without a migration.
Narrow the Google Fonts request to wght@400..700 with no italic axis:
the app uses only 400/500/600/700 and no italic anywhere, while the
previous Inter request pulled 100..900 plus the full italic range.
Manrope stays as the alternative in Settings > Appearance.
Findings from a cross-discipline interface review of the primary flow
(sign-in, dashboard, and the vehicle/driver/company lists and details).
The skip link pointed at #content, which no element defined, so the
first focusable control on every authenticated page did nothing. Give
<main> that id and make it focusable.
Hardcoded status colors failed WCAG AA: "Não autorizado" measured
2.02:1 and "Autorizado" 2.99:1 on the dark card, and the white-on-amber
badge 2.15:1 in both themes. Dark --muted-foreground sat at 3.77:1 over
the background, and it carries most of the secondary text. Raising it
to 0.78 clears AA on background and card; over --muted it reaches only
3.88:1, which still covers the icon and kbd uses that land there.
Also declare lang="pt-BR", translate the leftover template strings
(ConfirmDialog, the whole pagination, the skip link), label the three
list search inputs, announce live passages through a polite status
region, gate motion behind prefers-reduced-motion, let the search
inputs adapt instead of sitting at a fixed 150px, distinguish
filtered-empty from truly-empty table states, align the remaining
sentence-case dialog titles with the app's Title Case convention, and
use a real ellipsis character.
Shadcn Studio serves its whole catalog from one endpoint, so a single
@ss replaces the split @ss-components / @ss-blocks / @ss-themes trio
that only forced a guess about which catalog an item lived in.
The path segment right after /r/ is the STYLE slot, so it must carry
{style} (which resolves to new-york-v4 here). Omitting it makes the
server read 'components' or 'blocks' as a style name and answer
404 {"error":"Invalid style"} for every item.
Also add @shadcnuikit, the second paid vendor, which was configured in
sibling projects but missing here; its bearer token lives in the
gitignored frontend/.env next to the Studio credentials.
Fixes verified by the 3-agent adversarial bug hunt (hunter, skeptic,
referee). Grouped by area:
Security
- routes: webhook internal-network check now reads the real client IP
(rightmost X-Forwarded-For hop, same as the rate limiter) so the
unauthenticated /api/plate and /api/unifi are no longer reachable
from the internet through Traefik
- config: mask the DB password in the startup log
- frontend: remove the dead /sign-up template that logged the
plaintext password to the console
Correctness and data integrity
- passage_ingest: serialize ingestion per plate to close the debounce
check-then-act race (duplicate passages and duplicate alert fan-out)
- api_vehicles: delete now removes the plate's images from disk (were
left orphaned) and the delete dialog warns about history/image loss
- passagem_repository: cast plate params to bpchar[] to keep the index
on the hot passages queries
- camera_repository: reject out-of-range ids instead of truncating i64
to i32, which could act on the wrong camera
- api_vehicles: reject create with both a driver and a company
- api_users: block an admin from removing their own admin flag
- api_dashboard: clamp available spots to [0, total] and propagate DB
errors instead of reporting capacity as 0
- api_alerts: map FK violation (23503) to 404 instead of an opaque 500
- api_profile: validate that the email contains '@'
- api_drivers: measure name length by chars, not bytes
- dahua: fail list_plates on firmware truncation so reconcile flags the
camera instead of diffing a partial list
- image: passage thumbnails now honor the per-camera crop_percent
- dashboard: keep the live feed bounded instead of growing forever
Regenerated .sqlx for the changed queries and routeTree.gen.ts.
Replace the "Carregar mais" button with an IntersectionObserver
sentinel that triggers loadMore 200px before the bottom, with an
in-flight guard against duplicate page fetches.
Live SSE prepends no longer trim the list below what the user has
scrolled to load: trimming past the cap dropped on-screen items and
skewed nextOffset, skipping pages on the next fetch.
AlertService::process_alerts branched per channel with three copy-pasted
spawn/format/log blocks and threaded all three channel configs through a
6-arg signature. Replace it with a Notifier trait (services/notify/,
mirroring camera_driver) and three thin adapters wrapping the existing
Telegram/WhatsApp/Email transports. Each adapter decides whether the
recipient enabled its channel and has the contact, so the fan-out is
channel-agnostic and process_alerts shrinks to (&AppState, passage,
image). A new channel is one adapter and zero edits to the orchestrator.
Extract the byte-identical format_relative_time into utils. Add a fan_out
unit test via a fake Notifier (no DB, no network).
Behavior unchanged: same channels, messages, and fire-and-forget sends.
The plate webhook, the UniFi webhook, and the Dahua event stream each
re-implemented the same pipeline (debounce, whitelist, persist, image,
broadcast, alerts). Extract one deep ingest(Detection) module; the three
sources become thin adapters that build a Detection.
Fix UniFi: it skipped debounce and whitelist entirely, so its passages
were never deduped and never whitelist-flagged. The module runs both for
every source.
The image is a lazy provider awaited only after debounce, so the Dahua
snapshot fetch no longer runs on a debounced duplicate. Thumbnails are
unified to 400px sync; a bad webhook image degrades to no-image, not 500.
Add integration tests at the ingest interface (debounce dedup, whitelist
flag).
Collapse PlateValidator (runtime) and plate_normalizer (import) into one
Plate value type with parse/parse_many. Parse at each ingestion edge
(webhook, unifi, dahua worker, import) and pass the normalized &str
downstream, so a plate cannot exist un-normalized.
Fix whitelist reconcile: it kept its own trim+upper normalize that did
not strip separators, so "ABC-1D23" and "ABC1D23" compared unequal and
produced spurious add/remove diffs against the camera. Both sides now go
through Plate::parse.
Add CONTEXT.md domain glossary, leading with Plate.
After authorizing a plate the SPA now reports whether it reached the
registered cameras, instead of a generic success toast.
- Add GET /api/vehicles/{plate}/whitelist-status: the latest whitelist
queue op for the plate plus its per-camera execution status and the
registered-camera count (runtime queries, no migration).
- Poll it after create/assign-driver/assign-company and drive a single
evolving toast: loading -> "adicionada em N cameras" / "falha em X" /
still-syncing timeout.
Campo gate_type por câmera (default cancela) que dirige os rótulos da
UI: botões "Abrir/Fechar portão" vs "Abrir/Fechar cancela" e toasts com
concordância de gênero. Sem mudança de comportamento — o relé é o mesmo.
Make a Dahua camera's whitelist match the authorized set (vehicles
with a driver or company owner): remove plates that aren't authorized
and add the ones that are missing.
- CameraDriver::list_plates reads the camera whitelist (Dahua via
recordFinder find; Vzenith unsupported, so it's skipped).
- POST /api/cameras/{id}/resync + a per-Dahua-camera "Ressincronizar"
button (confirm dialog, toast with removed/added counts).
- Daily worker at 08:00 BRT reconciles every Dahua camera and emails
admins only when something changed or a camera couldn't be reached.
Câmeras Dahua não informam entrada/saída, então onde só há câmeras Dahua
(unidade SIMOT) a contagem de entradas/saídas do dashboard ficava zerada
mesmo com as placas sendo lidas.
Adiciona `fixed_direction` (in|out|NULL) por câmera, configurável no
formulário do front. NULL = automático (Vzenith deriva do payload, Dahua
sem sentido); quando setado, sobrepõe o payload em toda passagem da câmera.
- migration + coluna com CHECK (in|out)
- Camera / CameraRepository / api_cameras (input + validação 400)
- dahua_event_worker e webhook Vzenith passam a honrar o sentido fixo
- front: Select Automático/Entrada/Saída no cadastro de câmera
- ajusta os callers de CameraRepository::create/update nos testes
- delete não bloqueia mais quando há passagens; CameraRepository::remove
apaga em transação as passagens e whitelist_queue_execution do IP e então
a câmera (resolve as FKs ON DELETE RESTRICT)
- list/get/create/update passam a retornar CameraDto com passage_count
- UI: dialog de exclusão avisa quantas passagens serão apagadas (plural correto)
- backend: DriverVehicleItem ganha last_passage (Option<DateTime<Utc>>),
populado do timestamp da última passagem (já vinha do
get_last_passages_for_plates, só o thumbnail era usado).
- frontend: driverVehicleSchema + last_passage; cada veículo na lista do
motorista mostra 'Última passagem dd/MM/yyyy HH:mm' (ou 'Sem passagens')
com ícone de relógio.
Na página de detalhe do motorista o thumb dos veículos era size-9
(36px quadrado) e a linha não era clicável. Agora:
- thumb vira h-12 w-20 (paisagem, do tamanho da lista de veículos);
- placa + thumb envolvidos num Link pra /vehicles/$plate (clique leva
à página do veículo), com hover bg + underline na placa;
- botão de remover segue separado (não navega).
pretty() jogava o contexto de span do TraceLayer (ip/method/uri) numa
linha indentada separada, dobrando cada log de request. compact() colapsa
tudo numa linha só, mantendo o timestamp custom.
O firmware Dahua/Intelbras rejeita MasterOfCar > 15 bytes com HTTP 400
'Bad Request' (verificado empiricamente direto no CGI: 15=ok, 16=400).
Nomes de proprietário longos (>15 bytes) quebravam toda insert.
Agora o nome é truncado a 15 bytes em fronteira de char UTF-8 antes de
URL-encode: nome curto vai inteiro, longo vai truncado (melhor que nada).
removeEx+insert seguem idempotentes.
Testes: truncamento por byte + fronteira UTF-8 ('João') + nome longo.
Regressão do 96544fb: a note virou 'Proprietário: {nome}' (não-ASCII),
mas o protocolo binário Vzenith exige ASCII -> 'protocol error: note
must be ASCII' em toda câmera Vzenith. O nome do proprietário só é
relevante na Dahua (MasterOfCar); a Vzenith não tem campo de dono, então
a note volta ao label ASCII fixo e owner_name é ignorado lá.
Cada item processado agora mostra '— {done}/{total} ({pct}%) done' nas
linhas de completed/failed. O total é o tamanho do lote pendente busado
no início do ciclo, então num backfill de N placas vê-se 1/N..N/N.
'O Found N' debug virou INFO pra dar o total do lote logo de cara.
O add_plate só enviava a placa; a entrada da whitelist ficava sem o nome
do dono. Agora o campo MasterOfCar da TrafficRedList (Dahua) é populado.
- CameraDriver::add_plate ganha owner_name: Option<&str>
- DahuaDriver: anexa &MasterOfCar=<urlencoded> quando há dono (nomes têm
espaços/acentos). Omitido quando None (sem dono).
- VzenithDriver: usa o nome na note da placa.
- WhitelistQueueWorker: busca o proprietário (motorista OU empresa) por
placa e repassa ao driver.
- find_driver_name -> find_owner_name (COALESCE driver/company); alinha
com o fix D8 ('Proprietário'). Também popula o nome do dono exibido na
passagem de veículos de empresa (antes vinha vazio).
clippy -D warnings verde; 183 testes --lib ok.
O lint while_let_loop (commit anterior) mascarava 11 dead_code que o clippy
do target bin não rastreia: código usado por templates Askama e por testes
de integração (compilam o lib como dep externa).
Removidos (genuinamente mortos):
- DriverRepository::get_all
- EmailService::send_welcome_email
- WhatsappService::send_text / send_text_with_retry
- Company::formatted_updated_at (sem template consumindo)
#[allow(dead_code)] justificado:
- Camera/Driver formatted_*, Passage::formatted_datetime, PlateChar/placa_chars
-> renderizados por templates .html.j2 (código Askama não rastreado)
- PassageRepository::{find_by_id,delete_by_id,find_recent,
get_last_passage_time,find_by_plate_paginated}, VehicleRepository::list_all
-> usados por testes de integração
cargo clippy -- -D warnings agora verde; 182 testes --lib ok.
Loop manual com 'let Some(..) else { break }' vira while let; o segundo
ponto de saída (part incompleta, com drain antes do break) permanece como
let-else interno. Comportamento idêntico, só satisfaz o lint novo do
toolchain 1.96.
Câmeras cadastradas depois dos veículos já autorizados (ex.: a
Intelbras/Dahua) ficavam sem a lista de permissões: o trigger
vehicle_driver_whitelist_trigger só dispara em UPDATE de
driver_id/company_id, então a whitelist existente nunca era
sincronizada à câmera nova. Mesma classe do bug B3.
- migration 20260708000002: backfill único enfileirando ADD para
todo veículo com dono (idempotente via NOT EXISTS).
- CameraRepository create handler: ao criar câmera nova, chama
enqueue_backfill_for_owned_vehicles para retro-sincronizar
(evita reincidência).
- testes de integração cobrem o backfill e a idempotência.
Validado em Postgres descartável: veículo c/ dono e só ADD concluído
é reenfileirado; ADD pendente não duplica; empresa entra; sem dono
ignorado; re-run é no-op.