Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion docs/AI-INSTALL.md
Original file line number Diff line number Diff line change
Expand Up @@ -643,7 +643,11 @@ All wizard steps have API equivalents. Do them in order:
what the provider can carry: Discord/Telegram do `both`, Pushover/ntfy one
image (`both` sends the plate crop), Slack/webhook are text/link only. The
legacy `include_snapshot: true|false` still works (maps to `vehicle`/`none`).
Then prove it delivers:
`GET /notifications/channels` returns each channel with `global` (bool) and,
for an admin caller, every channel with `owner_username` (the owner, absent
for a global channel); an admin lists all channels, a non-admin only their
own. Quiet hours are whole hours 0..=23 (server local time); an out-of-range
value is rejected. Then prove it delivers:
`POST /notifications/channels/{id}/test` → `{ok, error?}`. Per-camera rules
and quiet hours are `PUT /notifications/rules[/{camera_id}]`.
9. **Additional users (optional).** `GET /config/roles` for the role list
Expand Down
2 changes: 1 addition & 1 deletion docs/COMPONENT-MAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -227,7 +227,7 @@ a "new camera capability" is usually also a "new/changed API endpoint" and a
| Alert-text templates | `crumb_common::alert_template` (the `%token%` engine + built-in default templates), `system_alert_rules.message_template`/`title_template` (migration `0079`), rendered in `channel_notify::ChannelMessage::text()`/`rendered_title()`, resolved in `notifications.rs`; per-type token vocab surfaced by `SYS_ALERT_META_TOKENS` in `admin.html`. A new `event_key` that wants structured tokens ALSO populates `system_events.meta` at its emit site (via `db::insert_system_event_full`) and adds a `SYS_ALERT_META_TOKENS` entry | `NULL` template = built-in default (byte-identical to the legacy wording); built-in tokens (`%camera%`/`%event%`/`%detail%`/date-time) work for every type with no meta. Rendered text is JSON-escaped by the `serde_json` provider bodies, so a template can never break or inject into a payload |
| Alert-suppression logic | `alerts.rs` grace/latch helpers (`within_boot_grace`, `within_camera_create_grace`, the per-camera `was_offline` latch) | A false alert costs more trust than a late one, but every suppression must EXPIRE — unit-test both the hold and the release (issues #46, #520) |
| Per-channel snapshot mode | `notification_channels.snapshot_mode` (migration `0080`, `'none'/'plate'/'vehicle'/'both'`) via `db::SnapshotMode`; the crop is derived by `channel_notify::crop_plate_jpeg` (server-side ffmpeg crop of the vehicle frame by `system_events.meta.plate_bbox`, or a crumb-alpr `plate_reads.crop`); the mode→image plan is `channel_notify::plan_images` gated by `provider_image_capability`; the console's `NOTIF_IMG_CAP`/`notifSnapModes` mirror that capability. Legacy `include_snapshot` is a synced mirror (`mode != 'none'`), no longer read by the engine | A provider can only deliver what its transport allows: Discord/Telegram carry two images (`both`), Pushover/ntfy one (`both`→plate crop), Slack/webhook zero (no byte path — text/link only). The capability map in `channel_notify.rs` and `admin.html` MUST stay in lock-step |
| Admin console | notifications section in `admin.html` | Rules/history CRUD |
| Admin console | notifications section in `admin.html` | Rules/history CRUD. Alert-text editing is one shared modal `#sysalert-editor-modal` (per-alert "✎ Customize"), NOT per-row inline. Quiet hours are whole-hour `<select>`s (0..=23, server rejects out-of-range) inside collapsed `details.detail-section`. Destinations show an owner chip; the "global" checkbox is admin-only (`GET /auth/me` gates it) and admins list ALL channels (`owner_username` attribution) |
| Clients | android polling/local-notification path, desktop toasts, iOS `Settings` | Delivery ends on a client |
| Env/config | rows B and I for any new channel credential (`ALERT_WEBHOOK_URL`, ntfy/Pushover keys) | Never log or hardcode channel secrets |
| Docs | `docs/AI-INSTALL.md` section 9 (monitoring/alerting); `docs-site/docs/notifications/*` | |
Expand Down
84 changes: 84 additions & 0 deletions docs/DECISIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,90 @@ revisit.

---

## 2026-08-08, Notifications pane: inline per-alert ✎ icon opens the alert-text editor in place, whole-hour quiet-hours pickers validated 0..=23 server-side, admins see every channel

**Context.** A Notifications-pane UX pass surfaced four issues. (1) The engine
fans out to **every enabled channel regardless of owner**
(`db::list_enabled_channels`), but the console's Destinations list called
`list_notification_channels` scoped to the caller's own + global channels, so a
channel created under a different account was invisible even to an admin — and
two round-trip defects made the "global" checkbox cosmetic (`ChannelResponse`
had no `global` field; `UpdateChannelRequest` silently dropped `global`).
(2) The system-alerts table rendered a full per-row `<details>` "Customize alert
text" block for all ~13 alerts — a wall of collapsibles. (3) Quiet hours were
raw `type=number` inputs; a value like `2200` was stored verbatim and only
clamped at read time (`in_quiet_hours` `clamp(0,23)`) to the zero-width window
`23..23`, so quiet hours **silently never fired** — no server-side validation
existed. (4) The Title field showed for every provider though only Pushover/ntfy
consume a rendered title.

**Decisions.**

- **Admins list ALL channels with owner attribution.** `list_notification_channels`
now returns every channel for an admin (via `list_all_notification_channels`,
a `LEFT JOIN users`), each with `owner_username`; non-admin scope (own only)
is unchanged. `ChannelResponse` gained `global: bool` and `owner_username`, and
`UpdateChannelRequest`/`UpdateChannelParams` gained an admin-only owner toggle
(`global` → `user_id = NULL`/claim). A non-admin supplying `global` on update
is rejected (403), not silently ignored. **Rejected:** filtering the engine
fan-out by owner instead (that fan-out is correct — a global/foreign channel
is a real destination); leaving the console blind to foreign channels (the
reported bug).

- **A small inline ✎ icon per alert opens the editor in place (no modal).** Each
system-alert row carries a compact pencil icon on its control row, inline right
next to "Bypass quiet hours"; clicking it expands the editor as a panel
directly under that row (`_sysAlertEditorBody` rendered into a `colspan` row by
`_renderSystemAlertsSection`; toggled via `sysAlertToggleEditor`, only one open
at a time). The icon itself carries the "customized" state (accent border + a
dot) when a template override is set, so no separate chip is needed and the
list reads as a clean list with a tiny icon per row. The panel holds the same
message textarea, conditional Title field, per-alert token legend
(click-to-insert, with sample values), live preview (ONE generic
notification-card, not per-provider chrome), Restore default, and it saves via
the existing per-alert PUT, decoupled from the row-toggle bulk save.
**Rejected:** a shared `.modal-overlay` dialog (the maintainer found a labeled
"Customize" button opening a separate modal too heavy for "just a little icon
to click"); the earlier status-quo inline `<details>` "Customize alert text"
row per alert (13× noise, a wall of collapsibles); a floating anchored popover
(positioning/collision math, and it clips inside the table's `overflow-x` wrap).
Inline expansion needs no anchoring and works identically in the desktop
WebView2 embed and any Android WebView. **NOTE:** this replaces the modal
affordance that the first cut of this PR shipped — do not "restore" the modal.

- **Whole-hour `<select>` quiet-hours pickers + server-side 0..=23 validation.**
Both quiet-hours pairs (per-user rules and system alerts) are now native
selects (`— off —` + 24 friendly 12-hour options), so an out-of-range or
minute value is unrepresentable, and the sections collapse into
`details.detail-section` with a live-state summary. The server rejects any
quiet-hours value outside `0..=23` (`do_upsert_rule`,
`put_notification_settings`) instead of storing junk; the engine-side clamp
stays as defense for any legacy rows, which the console renders as `— off —`
with a one-line "out of range, pick an hour" warning. **Rejected:** a
data-migration to scrub existing bad values (the clamp keeps them harmless and
the picker can't write a new bad one); free-text/military-time input (the
original bug surface).

- **Title field only when a configured destination consumes it** (Pushover or
ntfy present), with the helper naming the actual destinations. A stored title
is left untouched when the field is hidden (the PUT omits `title_template` =
keep).

**Trade-offs accepted.** The console now issues one extra `GET /auth/me` on the
Notifications pane load to know the session's user id + admin flag (for owner
attribution and gating the global checkbox). Quiet hours are whole-hour only
(minutes were never representable in the old 0–23 inputs either). `owner_username`
is display-only and populated solely by the admin listing's join, so every other
channel query path leaves it `None`.

**Revisit triggers.** A hosted/multi-tenant mode where "all channels" is too
broad for an admin (scope the listing per tenant). Demand for sub-hour quiet
windows (the picker + validation + `in_quiet_hours` would need a minute
component). A second event type that carries an image (the preview's image-slot
gate — currently `plate_watchlist_hit` only — generalizes).

---

## 2026-08-08, Home Assistant entity role labels in the camera editor are mode-aware (copy only)

**Context.** The per-camera HA link editor (`admin.html`, `renderHaLinks` &c.)
Expand Down
Loading
Loading