diff --git a/docs/AI-INSTALL.md b/docs/AI-INSTALL.md index a4642200..cab63300 100644 --- a/docs/AI-INSTALL.md +++ b/docs/AI-INSTALL.md @@ -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 diff --git a/docs/COMPONENT-MAP.md b/docs/COMPONENT-MAP.md index 6431c256..7396dbd8 100644 --- a/docs/COMPONENT-MAP.md +++ b/docs/COMPONENT-MAP.md @@ -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 `` 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.) diff --git a/services/api/src/admin.html b/services/api/src/admin.html index 12d964fa..167ede55 100644 --- a/services/api/src/admin.html +++ b/services/api/src/admin.html @@ -392,6 +392,14 @@ .modal-title{font-size:15px;font-weight:650;letter-spacing:-.01em;} .modal-close{background:transparent;border:none;color:var(--dim);font-size:20px;line-height:1;cursor:pointer;padding:2px 6px;border-radius:4px;} .modal-close:hover{color:var(--text);background:var(--surface2);} +/* ── Inline per-alert "customize text" affordance (system alerts table) ── */ +.sysalert-edit-icon{position:relative;background:transparent;border:1px solid var(--border2);color:var(--dim);font-size:13px;line-height:1;cursor:pointer;padding:3px 6px;border-radius:6px;flex:0 0 auto;} +.sysalert-edit-icon:hover{color:var(--text);background:var(--surface2);border-color:var(--border);} +.sysalert-edit-icon.open{color:var(--text);background:var(--surface2);border-color:var(--accent2);} +.sysalert-edit-icon.customized{color:var(--accent2);border-color:var(--accent2);} +.sysalert-edit-icon .dot{position:absolute;top:-3px;right:-3px;width:7px;height:7px;border-radius:50%;background:var(--accent2);border:1px solid var(--surface);} +.sae-inline-panel{border:1px solid var(--border2);border-left:2px solid var(--accent2);border-radius:8px;background:var(--surface2);padding:12px 14px;margin:2px 0 6px;} +.sae-inline-title{font-size:13px;font-weight:650;letter-spacing:-.01em;margin-bottom:10px;} .modal-actions{display:flex;gap:10px;margin-top:22px;justify-content:flex-end;} /* ── Group boxes (commercial-VMS fieldset/legend feel) ── Each section is a flat 1px-bordered panel; its small-caps label sits ON the @@ -7221,19 +7229,30 @@
${fmtBytes(used)} used${fmtBytes(g.free_bytes)} free of ${fmtBytes(total)}
` : `
No cache telemetry yet.
`; - const rows = cams.map(c => ` + const rows = cams.map(c => { + /* 0/0 is expected for a Motion camera that is actively persisting motion (its + ring is drained at motion start and stays empty through motion + post-roll) + or has fallen back to direct-to-storage — footage is safe in both. Flag it + with a tooltip rather than implying a fault. */ + const ringLabel = c.ring_segments == null ? '—' : c.ring_segments + ' seg'; + const ringTitle = (c.ring_segments === 0) ? ' title="0 is normal while motion is being persisted or just after an event"' : ''; + const ago = fmtAgo(c.updated_at); + return `
${esc(c.camera_name)} - ${c.ring_segments == null ? '—' : c.ring_segments + ' seg'} + ${esc(ringLabel)} ${c.ring_bytes == null ? '—' : fmtBytes(c.ring_bytes)} + ${ago ? 'as of ' + esc(ago) : ''} ${c.projected_ring_bytes != null ? 'projected ' + fmtBytes(c.projected_ring_bytes) : 'no estimate yet'} -
`).join(''); + `; + }).join(''); const table = cams.length ? `
Per-camera ring occupancy
+
A Motion camera's ring empties while motion is being persisted; 0 seg / 0 B during or after an event is normal and loses no footage.
- CameraRingRing sizeProjected + CameraRingRing sizeUpdatedProjected
${rows}
Total projected across Motion cameras: ${esc(fmtBytes(st.total_projected_bytes))}
` @@ -10026,6 +10045,9 @@ let NOTIF_SYS_QUIET_START = null; /* system-alerts global quiet hours, from /notifications/settings */ let NOTIF_SYS_QUIET_END = null; let NOTIF_SYS_ALERTS = []; /* loaded array of system_alert_rules rows (P0-HEALTH-NOTIFY) */ +let NOTIF_ME_ID = null; /* current user's id, for channel owner attribution */ +let NOTIF_IS_ADMIN = false; /* current user is admin (governs the global toggle + all-channel list) */ +let SYS_ALERT_EDIT_KEY = null; /* event_key of the alert open in the system-alert editor modal */ /* Human-readable title + short description for each system-alert event_key. */ const SYS_ALERT_META = { @@ -10090,6 +10112,54 @@ return SYS_ALERT_BUILTIN_TOKENS.concat(SYS_ALERT_META_TOKENS[eventKey] || []); } +/* ── Quiet-hours whole-hour pickers (shared by rules + system alerts) ── + Native - - -
- Tokens for this alert: ${esc(tokenHint)}. - An unknown token is left as-is so a typo is visible. Times are UTC. -
-
- Preview: ${esc(_previewAlertTemplate(previewSrc, k))} -
- - - - - `; + /* Small inline pencil next to "Bypass quiet hours" opens the alert-text + editor in place. A filled/accent state + dot flags a customized alert + at a glance, so the list stays clean (no per-row "Customize" row/chip). */ + const customized = (r.message_template != null || r.title_template != null); + const isOpen = (SYS_ALERT_EDIT_KEY === r.event_key); + const iconCls = 'sysalert-edit-icon' + (customized ? ' customized' : '') + (isOpen ? ' open' : ''); + const iconTitle = customized ? 'Customize alert text (customized)' : 'Customize alert text'; + const editIcon = ``; + const editorRow = isOpen + ? `${_sysAlertEditorBody(r.event_key)}` + : ''; return ` @@ -10884,14 +10955,23 @@ ${thresholdField} - +
+ + ${editIcon} +
- ${templateRow}`; + ${editorRow}`; }).join(''); + /* Quiet-hours window (system alerts) — whole-hour selects, collapsed. */ + const badRange = quietHourOutOfRange(NOTIF_SYS_QUIET_START) || quietHourOutOfRange(NOTIF_SYS_QUIET_END); + const rangeWarn = badRange + ? `
A stored quiet-hours value is out of range (0–23) and is ignored. Pick an hour and save to fix it.
` + : ''; + return `
System alerts
@@ -10903,18 +10983,25 @@ even during the quiet-hours window below (recommended for footage-loss-critical events).
-
-
- - -
-
- - +
+ + + Quiet hours + ${esc(quietHoursSummary(NOTIF_SYS_QUIET_START, NOTIF_SYS_QUIET_END))} + +
+
+ Server local time. Applies only to system alerts that do NOT bypass quiet hours. Set both to "— off —" to disable. +
+
+ Quiet from + + to + +
+ ${rangeWarn}
-
+
@@ -10928,7 +11015,11 @@ async function notifSaveSystemAlerts() { setMsg('notif-sysalerts-msg', ''); - const rows = document.querySelectorAll('#notif-sysalerts-section tr[data-key]:not(.sysalert-tpl-row)'); + // Row toggles + thresholds only. Alert-text templates are edited and saved + // independently through the per-alert editor modal (sysAlertEditorSave), so + // this bulk save no longer reads or sends message_template/title_template + // (absent fields = keep, per the all-optional PUT). + const rows = document.querySelectorAll('#notif-sysalerts-section tr[data-key]'); const calls = []; rows.forEach(tr => { const key = tr.dataset.key; @@ -10944,12 +11035,6 @@ body.threshold_fraction = raw !== '' ? (parseFloat(raw) / 100) : null; } } - // Alert-text templates (migration 0079). A blank field clears to null on the - // server = "use the built-in default" (one-click restore). - const msgEl = document.querySelector('.sysalert-msg-tpl[data-key="' + CSS.escape(key) + '"]'); - const titleEl = document.querySelector('.sysalert-title-tpl[data-key="' + CSS.escape(key) + '"]'); - if (msgEl) { const v = msgEl.value.trim(); body.message_template = v !== '' ? v : null; } - if (titleEl) { const v = titleEl.value.trim(); body.title_template = v !== '' ? v : null; } calls.push(api('/notifications/system-alerts/' + encodeURIComponent(key), { method: 'PUT', body: JSON.stringify(body) })); }); @@ -10975,24 +11060,214 @@ } } -/* Live-preview the message template for one alert type using sample token - values. Falls back to the default template when the field is blank. */ -function sysAlertPreview(key) { - const msgEl = document.querySelector('.sysalert-msg-tpl[data-key="' + CSS.escape(key) + '"]'); - const out = document.querySelector('.sysalert-preview[data-key="' + CSS.escape(key) + '"]'); - if (!out) return; - const src = (msgEl && msgEl.value.trim() !== '') ? msgEl.value : SYS_ALERT_DEFAULT_MSG_TPL; - out.textContent = _previewAlertTemplate(src, key); +/* ── System-alert text editor (inline, expands under the alert's row) ── + Opened per alert from the small inline ✎ icon next to "Bypass quiet hours". + Contains the message textarea, a conditional title field, a per-alert token + legend, and a live preview rendered as a generic notification card. Save + issues the per-alert PUT (only message_template/title_template), decoupled + from the row-toggle bulk "Save system alerts". */ +let SAE_IMG_URL = null; /* fetched sample snapshot URL for the preview card, or null */ + +/* True when the current message/title (as typed) would deliver an image for + this alert: a channel must attach one AND the event type must carry one. */ +function sysAlertImageWouldDeliver(key) { + if (key !== 'plate_watchlist_hit') return false; // only this event carries an image today + return NOTIF_CHANNELS.some(ch => + ch.snapshot_mode && ch.snapshot_mode !== 'none' && (NOTIF_IMG_CAP[ch.kind] || 'none') !== 'none'); +} +/* True when a configured destination consumes the rendered title (Pushover/ntfy). */ +function sysAlertTitleRelevant() { + return NOTIF_CHANNELS.some(ch => ch.kind === 'pushover' || ch.kind === 'ntfy'); +} +/* Names of the Pushover/ntfy destinations that would use the title. */ +function sysAlertTitleDestNames() { + const names = NOTIF_CHANNELS.filter(ch => ch.kind === 'pushover' || ch.kind === 'ntfy').map(ch => ch.name); + return names.length ? names.join(', ') : 'Pushover and ntfy'; +} +/* Inline 16:9 gray placeholder (camera glyph) used when the sample snapshot + can't load — data URI so it needs no network. */ +const SAE_IMG_PLACEHOLDER = + 'data:image/svg+xml;utf8,' + encodeURIComponent( + '' + + '' + + '' + + '' + + ''); + +/* Build the inline editor panel (message textarea, conditional title, per-alert + token legend, live preview, actions). Rendered directly under the alert's row + by _renderSystemAlertsSection when this alert is the one open — no modal. */ +function _sysAlertEditorBody(key) { + const meta = SYS_ALERT_META[key] || { title: key }; + const r = (NOTIF_SYS_ALERTS.find(x => x.event_key === key)) || {}; + const msgVal = r.message_template != null ? r.message_template : ''; + const titleVal = r.title_template != null ? r.title_template : ''; + const titleRelevant = sysAlertTitleRelevant(); + + /* Token legend: %token% | sample value, clickable to insert at the caret. */ + const legendRows = _sysAlertTokens(key).map(t => { + const sample = (t === 'event') ? meta.title : (SYS_ALERT_TOKEN_SAMPLES[t] != null ? SYS_ALERT_TOKEN_SAMPLES[t] : ''); + return ` + + `; + }).join(''); + + const titleField = titleRelevant ? ` + + +
Used by: ${esc(sysAlertTitleDestNames())}.
` + : ''; + + return ` +
+
Customize: ${esc(meta.title || key)}
+ + + ${titleField} + +
+
Tokens for this alert
+
Click a token to insert it. An unknown token is left as-is so a typo is visible. Times are UTC.
+
%${esc(t)}%${esc(sample)}
${legendRows}
+
+ +
+
Preview
+
+
Delivery varies by provider; Discord and Telegram attach up to two images, Pushover and ntfy one, Slack and webhooks link only.
+
+ + +
+ `; } -/* "Restore default": clear both template fields so the next save stores NULL, - which the server renders as the built-in default for this alert type. */ -function sysAlertRestoreDefault(key) { - const msgEl = document.querySelector('.sysalert-msg-tpl[data-key="' + CSS.escape(key) + '"]'); - const titleEl = document.querySelector('.sysalert-title-tpl[data-key="' + CSS.escape(key) + '"]'); - if (msgEl) msgEl.value = ''; - if (titleEl) titleEl.value = ''; - sysAlertPreview(key); +/* Toggle the inline editor for one alert. Opening another closes the previous + (only one open at a time, keeps the list clean). Re-renders the section so the + expander row appears/collapses, then lazily fetches the sample snapshot for + image-bearing alerts and re-previews. */ +async function sysAlertToggleEditor(key) { + const opening = (SYS_ALERT_EDIT_KEY !== key); + SYS_ALERT_EDIT_KEY = opening ? key : null; + SAE_IMG_URL = null; + const sec = $('notif-sysalerts-section'); + if (sec) sec.innerHTML = _renderSystemAlertsSection(); + if (!opening) return; + sysAlertEditorPreview(); + /* Fetch a sample snapshot once (only when an image would actually deliver); + the preview card reuses it across keystrokes with an SVG fallback. */ + if (sysAlertImageWouldDeliver(key) && CAMS.length) { + try { SAE_IMG_URL = await snapshotUrl(CAMS[0].id); } catch { SAE_IMG_URL = null; } + if (SYS_ALERT_EDIT_KEY === key) sysAlertEditorPreview(); + } +} + +/* Collapse the inline editor (re-renders the section without the expander row). */ +function sysAlertCloseEditor() { + SYS_ALERT_EDIT_KEY = null; + SAE_IMG_URL = null; + const sec = $('notif-sysalerts-section'); + if (sec) sec.innerHTML = _renderSystemAlertsSection(); +} + +/* Insert a %token% at the message textarea's caret (no execCommand). */ +function sysAlertEditorInsertToken(tok) { + const el = $('sae-msg'); + if (!el) return; + const s = el.selectionStart != null ? el.selectionStart : el.value.length; + const e = el.selectionEnd != null ? el.selectionEnd : el.value.length; + el.value = el.value.slice(0, s) + tok + el.value.slice(e); + const caret = s + tok.length; + el.focus(); + el.setSelectionRange(caret, caret); + sysAlertEditorPreview(); +} + +/* Render the live preview as a generic push-notification card. */ +function sysAlertEditorPreview() { + const key = SYS_ALERT_EDIT_KEY; + const out = $('sae-preview'); + if (!key || !out) return; + const msgEl = $('sae-msg'); + const titleEl = $('sae-title-input'); + const msgSrc = (msgEl && msgEl.value.trim() !== '') ? msgEl.value : SYS_ALERT_DEFAULT_MSG_TPL; + const titleSrc = (titleEl && titleEl.value.trim() !== '') ? titleEl.value : SYS_ALERT_DEFAULT_TITLE_TPL; + const bodyText = _previewAlertTemplate(msgSrc, key); + const titleText = _previewAlertTemplate(titleSrc, key); + + const showImg = sysAlertImageWouldDeliver(key); + let imgHtml = ''; + if (showImg) { + const src = SAE_IMG_URL || SAE_IMG_PLACEHOLDER; + imgHtml = ``; + } else { + /* Distinguish "this alert never carries an image" from "an image-capable + destination just isn't configured" so the operator knows which to fix. */ + const anyImgChannel = NOTIF_CHANNELS.some(ch => (NOTIF_IMG_CAP[ch.kind]||'none')!=='none' && ch.snapshot_mode && ch.snapshot_mode!=='none'); + const why = (key === 'plate_watchlist_hit' && !anyImgChannel) + ? 'No destination attaches images' + : 'No image for this alert type'; + imgHtml = `${esc(why)}`; + } + + out.innerHTML = ` +
+
+
+ + Crumbnow +
+
${esc(titleText)}
+
${esc(bodyText)}
+
+ ${imgHtml} +
`; +} + +/* Clear both fields (next save stores NULL = built-in default) and re-preview. */ +function sysAlertEditorRestoreDefault() { + const msgEl = $('sae-msg'); if (msgEl) msgEl.value = ''; + const titleEl = $('sae-title-input'); if (titleEl) titleEl.value = ''; + sysAlertEditorPreview(); +} + +/* Save the per-alert templates via PUT, update the in-memory row + chip, close. */ +async function sysAlertEditorSave() { + const key = SYS_ALERT_EDIT_KEY; + if (!key) return; + const line = $('sae-msg-line'); + if (line) line.textContent = ''; + const msgEl = $('sae-msg'); + const titleEl = $('sae-title-input'); + const body = {}; + const msgVal = msgEl ? msgEl.value.trim() : ''; + body.message_template = msgVal !== '' ? msgVal : null; + /* Only send title_template when the field is rendered (Pushover/ntfy present); + when hidden, omit it so a stored title is left untouched (absent = keep). */ + if (titleEl) { const v = titleEl.value.trim(); body.title_template = v !== '' ? v : null; } + + try { + await api('/notifications/system-alerts/' + encodeURIComponent(key), { method: 'PUT', body: JSON.stringify(body) }); + /* Reflect the change in the in-memory row so the icon's customized state + updates when the section re-renders. */ + let row = NOTIF_SYS_ALERTS.find(x => x.event_key === key); + if (!row) { row = { event_key: key }; NOTIF_SYS_ALERTS.push(row); } + row.message_template = body.message_template; + if ('title_template' in body) row.title_template = body.title_template; + toast('Alert text saved.'); + sysAlertCloseEditor(); /* collapses the editor and re-renders the section */ + } catch (e) { + if (line) { line.textContent = e.message; line.classList.add('err'); } + } } /* Render the Configure form, either for a new channel (ch=null, uses NOTIF_NEW_KIND) @@ -11075,10 +11350,10 @@ Enabled - ` : ''} @@ -11162,33 +11437,44 @@ -
-
Quiet hours ${infoHint('Both hours are inclusive (0–23 wall-clock). Leave blank to disable quiet hours.')}
-
-
- - +
+ + + Quiet hours + ${esc(quietHoursSummary(def.quiet_start_hour, def.quiet_end_hour))} + +
+
+ Server local time. Set both to "— off —" to disable quiet hours.
-
- - +
+ Quiet from + + to +
+ ${(quietHourOutOfRange(def.quiet_start_hour) || quietHourOutOfRange(def.quiet_end_hour)) + ? `
A stored quiet-hours value is out of range (0–23) and is ignored. Pick an hour and save to fix it.
` : ''}
-
+
${CAMS.length ? ` -
-
Per-camera overrides
-
"Default" inherits the mode set above. Only non-Default rows are saved.
-
- - - ${camOverrideRows} -
CameraMode
+
+ + + Per-camera overrides + ${(() => { const n = NOTIF_RULES.filter(r => r.camera_id && (r.presence_mode||'default')!=='default').length; return n ? `${n} set` : 'none set'; })()} + +
+
"Default" inherits the mode set above. Only non-Default rows are saved.
+
+ + + ${camOverrideRows} +
CameraMode
+
-
` : ''}`; + ` : ''}`; } /* ── Open / close the Configure panel ── */ @@ -11274,13 +11560,18 @@ omits it and the stored value is untouched. */ const modeEl = $('notif-f-snapshot-mode'); + /* Global toggle is admin-only and only rendered for admins. When the checkbox + is absent (non-admin session) leave it undefined so the save omits it — the + server rejects a non-admin that supplies `global` on edit. */ + const globalEl = $('notif-f-global'); + return { name: name.trim(), kind, camera_ids, snapshot_mode: modeEl ? modeEl.value : undefined, enabled: !!($('notif-f-enabled') || {}).checked, - global: !!($('notif-f-global') || {}).checked, + global: globalEl ? !!globalEl.checked : undefined, _config: config, _anyNewSecrets: anyNew, }; @@ -11300,8 +11591,8 @@ config: body._config, camera_ids: body.camera_ids, enabled: body.enabled, - global: body.global, }; + if (body.global !== undefined) payload.global = body.global; if (body.snapshot_mode !== undefined) payload.snapshot_mode = body.snapshot_mode; try { @@ -11330,8 +11621,8 @@ name: body.name, camera_ids: body.camera_ids, enabled: body.enabled, - global: body.global, }; + if (body.global !== undefined) payload.global = body.global; if (body.snapshot_mode !== undefined) payload.snapshot_mode = body.snapshot_mode; if (body._anyNewSecrets) payload.config = body._config; diff --git a/services/api/src/notifications.rs b/services/api/src/notifications.rs index 30bdc439..b43a7bf9 100644 --- a/services/api/src/notifications.rs +++ b/services/api/src/notifications.rs @@ -266,6 +266,25 @@ async fn list_rules( Ok(Json(rules)) } +/// Reject a quiet-hours value that is not a whole hour in `0..=23`. +/// +/// Quiet-hours are whole-hour, server-local wall-clock values. Before this +/// check a bad value (e.g. `2200` pasted as military time) was stored verbatim +/// and only clamped at read time by [`in_quiet_hours`] (`clamp(0, 23)`), which +/// turned `start=2200,end=700` into the zero-width window `23..23` — quiet +/// hours then silently never fired. Rejecting at write time makes the failure +/// visible instead of a stored no-op. `None` is always valid (means "unset"). +fn validate_quiet_hour(hour: Option, field: &str) -> Result<(), ApiError> { + if let Some(h) = hour { + if !(0..=23).contains(&h) { + return Err(ApiError::BadRequest(format!( + "{field} must be a whole hour in 0..=23 (server local time); got {h}" + ))); + } + } + Ok(()) +} + /// Shared logic for upserting a rule for `(user_id, camera_id)`. async fn do_upsert_rule( pool: &Pool, @@ -279,6 +298,8 @@ async fn do_upsert_rule( "presence_mode must be 'off', 'away_only', or 'always'; got '{presence_mode}'" ))); } + validate_quiet_hour(body.quiet_start_hour, "quiet_start_hour")?; + validate_quiet_hour(body.quiet_end_hour, "quiet_end_hour")?; let p = db::UpsertNotificationRuleParams { user_id, camera_id, @@ -484,6 +505,12 @@ pub struct UpdateChannelRequest { pub snapshot_mode: Option, /// Legacy snapshot toggle; used only when `snapshot_mode` is absent. pub include_snapshot: Option, + /// Admin-only global toggle. Omit to keep the stored owner; `true` → make + /// the channel global (`user_id = NULL`); `false` → claim ownership for the + /// caller. A non-admin supplying this field is rejected (403); create-time + /// `global` was already honored via `CreateChannelRequest`, but on edit this + /// field was previously dropped by serde, so the checkbox did nothing. + pub global: Option, } /// A channel row with secrets masked, safe for API responses. @@ -494,6 +521,16 @@ pub struct UpdateChannelRequest { pub struct ChannelResponse { pub id: Uuid, pub user_id: Option, + /// `true` when the channel is global (no owner, `user_id IS NULL`). Kept as + /// an explicit field because the console renders the "Make available to all + /// users (global)" checkbox from it — deriving it client-side from + /// `user_id` is fragile (older clients read a missing field as unchecked). + pub global: bool, + /// Owner's username for admin attribution, or `None` for a global channel + /// (and always `None` in a non-admin listing, which only returns own + /// channels). Populated by the admin-scoped listing's join on `users`. + #[serde(skip_serializing_if = "Option::is_none")] + pub owner_username: Option, pub kind: String, pub name: String, pub enabled: bool, @@ -515,6 +552,8 @@ impl ChannelResponse { let masked_config = channel_notify::mask_channel_config(&ch.config); Self { id: ch.id, + global: ch.user_id.is_none(), + owner_username: ch.owner_username, user_id: ch.user_id, kind: ch.kind, name: ch.name, @@ -697,6 +736,20 @@ async fn update_channel( existing.snapshot_mode, )?; + // Global toggle is admin-only. Non-admins must not be able to promote a + // channel to global (a system-wide destination) or claim/relinquish + // ownership; reject rather than silently ignore so the behavior is honest. + let set_owner = match body.global { + None => None, + Some(_) if !user.is_admin() => { + return Err(ApiError::Forbidden( + "only admins may change a channel's global scope".to_owned(), + )); + } + Some(true) => Some(None), // → global (user_id = NULL) + Some(false) => Some(Some(user.user_id)), // → claim ownership + }; + let params = db::UpdateChannelParams { id, name: body @@ -710,6 +763,7 @@ async fn update_channel( config: body.config, // None = keep stored camera_ids: body.camera_ids.or(existing.camera_ids), snapshot_mode, + set_owner, }; let ch = db::update_notification_channel(state.pool(), ¶ms) @@ -866,6 +920,8 @@ async fn put_notification_settings( State(state): State, Json(body): Json, ) -> Result, ApiError> { + validate_quiet_hour(body.system_quiet_start_hour, "system_quiet_start_hour")?; + validate_quiet_hour(body.system_quiet_end_hour, "system_quiet_end_hour")?; db::set_notifications_enabled(state.pool(), body.enabled) .await .context("set_notifications_enabled")?; diff --git a/services/api/tests/notification_pane.rs b/services/api/tests/notification_pane.rs new file mode 100644 index 00000000..6568f0e2 --- /dev/null +++ b/services/api/tests/notification_pane.rs @@ -0,0 +1,292 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +//! Integration tests for the Notifications-pane redesign (WU-1 + WU-4 server +//! halves): +//! +//! * **Quiet-hours range validation** — a whole-hour quiet-hours value must be +//! in `0..=23`. Before this, a bad value like `2200` (military time pasted +//! into the old number input) was stored verbatim and only clamped at read +//! time to a zero-width window, so quiet hours silently never fired. Both the +//! per-user rules endpoint and the admin system-alerts settings endpoint are +//! covered. +//! * **Admin lists ALL channels with owner attribution** — the engine fans out +//! to every enabled channel regardless of owner, but the console previously +//! listed only the caller's own + global channels, hiding a channel created +//! under another account. An admin must now see every channel with the +//! owner's username; a non-admin still sees only their own. +//! * **Global-flag round-trip on update** — `ChannelResponse` exposes `global`, +//! and toggling it on an update actually persists (`user_id = NULL`). A +//! non-admin may not change a channel's global scope (403). +//! +//! Same harness as `notification_channel_rbac.rs`: `tests/support` re-includes +//! the real `src/` modules so these exercise the actual handlers. +#![allow(clippy::module_name_repetitions)] +#![allow(clippy::too_many_lines)] + +mod support; + +use axum::http::StatusCode; +use deadpool_postgres::Pool; +use uuid::Uuid; + +use crumb_common::db; + +use support::*; + +/// Read a response body into a JSON value. +async fn into_json(resp: axum::http::Response) -> serde_json::Value { + let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX) + .await + .expect("read body"); + serde_json::from_slice(&bytes).unwrap_or(serde_json::Value::Null) +} + +/// Create a channel owned by `user_id` (or global when `None`) directly in the +/// DB, bypassing the create handler so the test controls ownership precisely. +async fn seed_channel(pool: &Pool, user_id: Option, name: &str) -> Uuid { + db::create_notification_channel( + pool, + &db::CreateChannelParams { + user_id, + kind: "webhook".to_owned(), + name: name.to_owned(), + enabled: true, + config: serde_json::json!({}), + camera_ids: None, + snapshot_mode: db::SnapshotMode::None, + }, + ) + .await + .expect("seed channel") + .id +} + +// ─── quiet-hours validation ────────────────────────────────────────────────── + +#[tokio::test] +async fn rule_quiet_hours_out_of_range_is_rejected() { + let app = TestApp::new().await; + let user = seed_viewer(app.pool(), &[]).await; + let token = login(&app, &user.username, &user.password).await; + + // 2200 (military time) must be rejected, not stored + clamped. + for bad in [2200, 24, -1, 700] { + let body = serde_json::json!({ "quiet_start_hour": bad, "quiet_end_hour": 7 }); + let resp = app + .send(put_auth_json("/notifications/rules", &token, &body)) + .await; + assert_eq!( + resp.status(), + StatusCode::BAD_REQUEST, + "quiet_start_hour={bad} must be rejected" + ); + } + + // The end hour is validated too. + let body = serde_json::json!({ "quiet_start_hour": 22, "quiet_end_hour": 99 }); + let resp = app + .send(put_auth_json("/notifications/rules", &token, &body)) + .await; + assert_eq!(resp.status(), StatusCode::BAD_REQUEST, "quiet_end_hour=99"); + + // Valid whole-hour windows (including the 0 and 23 boundaries) are accepted. + for (s, e) in [(22, 7), (0, 23), (23, 0)] { + let body = serde_json::json!({ "quiet_start_hour": s, "quiet_end_hour": e }); + let resp = app + .send(put_auth_json("/notifications/rules", &token, &body)) + .await; + assert_eq!( + resp.status(), + StatusCode::OK, + "quiet hours {s}..{e} must be accepted" + ); + } + + // Absent quiet hours (unset) is always valid. + let body = serde_json::json!({ "presence_mode": "always" }); + let resp = app + .send(put_auth_json("/notifications/rules", &token, &body)) + .await; + assert_eq!(resp.status(), StatusCode::OK, "unset quiet hours is valid"); +} + +#[tokio::test] +async fn system_alert_quiet_hours_out_of_range_is_rejected() { + let app = TestApp::new().await; + let admin = seed_admin(app.pool()).await; + let token = login(&app, &admin.username, &admin.password).await; + + let bad = serde_json::json!({ + "enabled": true, + "system_quiet_start_hour": 2200, + "system_quiet_end_hour": 7, + }); + let resp = app + .send(put_auth_json("/notifications/settings", &token, &bad)) + .await; + assert_eq!( + resp.status(), + StatusCode::BAD_REQUEST, + "system_quiet_start_hour=2200 must be rejected" + ); + + let ok = serde_json::json!({ + "enabled": true, + "system_quiet_start_hour": 0, + "system_quiet_end_hour": 23, + }); + let resp = app + .send(put_auth_json("/notifications/settings", &token, &ok)) + .await; + assert_eq!( + resp.status(), + StatusCode::OK, + "a valid 0..23 system quiet-hours window must be accepted" + ); +} + +// ─── admin lists all channels with owner attribution ───────────────────────── + +#[tokio::test] +async fn admin_lists_all_channels_with_owner_attribution() { + let app = TestApp::new().await; + let pool = app.pool(); + + let admin = seed_admin(pool).await; + let viewer = seed_viewer(pool, &[]).await; + + let owned_by_viewer = seed_channel(pool, Some(viewer.user_id), &unique("viewer-ch")).await; + let global_ch = seed_channel(pool, None, &unique("global-ch")).await; + + // Admin: sees BOTH, the foreign one attributed to its owner. + let atok = login(&app, &admin.username, &admin.password).await; + let list = into_json(app.send(get_auth("/notifications/channels", &atok)).await).await; + let arr = list.as_array().expect("channels array"); + + let vrow = arr + .iter() + .find(|c| c["id"].as_str() == Some(&owned_by_viewer.to_string())) + .expect("admin must see the viewer-owned channel"); + assert_eq!( + vrow["global"].as_bool(), + Some(false), + "an owned channel is not global" + ); + assert_eq!( + vrow["owner_username"].as_str(), + Some(viewer.username.as_str()), + "the foreign channel must carry its owner's username" + ); + + let grow = arr + .iter() + .find(|c| c["id"].as_str() == Some(&global_ch.to_string())) + .expect("admin must see the global channel"); + assert_eq!(grow["global"].as_bool(), Some(true), "global flag set"); + + // Non-admin: sees ONLY their own channel, never the global one. + let vtok = login(&app, &viewer.username, &viewer.password).await; + let vlist = into_json(app.send(get_auth("/notifications/channels", &vtok)).await).await; + let varr = vlist.as_array().expect("channels array"); + assert!( + varr.iter() + .any(|c| c["id"].as_str() == Some(&owned_by_viewer.to_string())), + "a non-admin sees their own channel" + ); + assert!( + !varr + .iter() + .any(|c| c["id"].as_str() == Some(&global_ch.to_string())), + "a non-admin must not see a global channel in this listing" + ); +} + +// ─── global-flag round-trip on update + RBAC ───────────────────────────────── + +#[tokio::test] +async fn admin_can_toggle_global_and_it_round_trips() { + let app = TestApp::new().await; + let admin = seed_admin(app.pool()).await; + let token = login(&app, &admin.username, &admin.password).await; + + // Create an OWNED channel (global:false) as the admin. + let create = serde_json::json!({ + "kind": "webhook", + "name": unique("toggle-ch"), + "config": {}, + "global": false, + }); + let resp = app + .send(post_auth_json("/notifications/channels", &token, &create)) + .await; + assert_eq!(resp.status(), StatusCode::CREATED); + let created = into_json(resp).await; + assert_eq!(created["global"].as_bool(), Some(false)); + let id = created["id"].as_str().expect("id").to_owned(); + + // Toggle global ON via update. + let upd = serde_json::json!({ "global": true }); + let resp = app + .send(put_auth_json( + &format!("/notifications/channels/{id}"), + &token, + &upd, + )) + .await; + assert_eq!(resp.status(), StatusCode::OK); + let updated = into_json(resp).await; + assert_eq!( + updated["global"].as_bool(), + Some(true), + "the update response reflects the new global flag" + ); + assert!( + updated["user_id"].is_null(), + "a global channel has no owner (user_id NULL)" + ); + + // Read it back through the admin listing: still global. + let list = into_json(app.send(get_auth("/notifications/channels", &token)).await).await; + let row = list + .as_array() + .unwrap() + .iter() + .find(|c| c["id"].as_str() == Some(id.as_str())) + .expect("channel present"); + assert_eq!( + row["global"].as_bool(), + Some(true), + "global flag persisted across a reload" + ); +} + +#[tokio::test] +async fn non_admin_cannot_toggle_global() { + let app = TestApp::new().await; + let viewer = seed_viewer(app.pool(), &[]).await; + let token = login(&app, &viewer.username, &viewer.password).await; + + // Viewer creates their own channel (global is hidden for them; server + // ignores it on create for non-admins anyway). + let create = serde_json::json!({ "kind": "webhook", "name": unique("v-ch"), "config": {} }); + let resp = app + .send(post_auth_json("/notifications/channels", &token, &create)) + .await; + assert_eq!(resp.status(), StatusCode::CREATED); + let id = into_json(resp).await["id"].as_str().unwrap().to_owned(); + + // Attempting to promote it to global must be rejected. + let upd = serde_json::json!({ "global": true }); + let resp = app + .send(put_auth_json( + &format!("/notifications/channels/{id}"), + &token, + &upd, + )) + .await; + assert_eq!( + resp.status(), + StatusCode::FORBIDDEN, + "a non-admin must not change a channel's global scope" + ); +} diff --git a/services/api/tests/support/mod.rs b/services/api/tests/support/mod.rs index 1e1bd2e8..59a44161 100644 --- a/services/api/tests/support/mod.rs +++ b/services/api/tests/support/mod.rs @@ -192,6 +192,14 @@ fn ensure_env() { if std::env::var("JWT_EXPIRY_SECONDS").is_err() { std::env::set_var("JWT_EXPIRY_SECONDS", "86400"); } + // Keep each test's pool SMALL. Every `#[tokio::test]` builds its own + // `AppState` (its own pool), and they run concurrently against ONE shared + // Postgres; at the stock code default (32) enough concurrent tests exhaust a + // stock `max_connections = 100` (a prior PR hit exactly this). A handful of + // connections is plenty — no test needs deep concurrency within itself. + if std::env::var("DB_POOL_SIZE").is_err() { + std::env::set_var("DB_POOL_SIZE", "4"); + } } // Guards the migration run so it executes exactly once per test-binary diff --git a/services/common/src/db.rs b/services/common/src/db.rs index d4a983b8..9dfcec47 100644 --- a/services/common/src/db.rs +++ b/services/common/src/db.rs @@ -12256,6 +12256,11 @@ pub struct NotificationChannel { pub snapshot_mode: SnapshotMode, pub created_at: DateTime, pub updated_at: DateTime, + /// Username of the channel's owner, or `None` for a global channel. Only + /// populated by the admin-scoped [`list_all_notification_channels`] listing + /// (which joins `users`); every other query path leaves this `None` because + /// it is display-only owner attribution, not part of the channel identity. + pub owner_username: Option, } fn notification_channel_from_row(row: &tokio_postgres::Row) -> NotificationChannel { @@ -12271,6 +12276,7 @@ fn notification_channel_from_row(row: &tokio_postgres::Row) -> NotificationChann snapshot_mode: SnapshotMode::from_db(&row.get::<_, String>("snapshot_mode")), created_at: row.get("created_at"), updated_at: row.get("updated_at"), + owner_username: None, } } @@ -12342,9 +12348,14 @@ pub async fn create_notification_channel( /// List notification channels visible to `user_id`. /// -/// Returns the caller's own channels plus global channels (`user_id IS NULL`). -/// When `include_globals` is true (Admin callers should pass true so they also -/// see global channels), global channels are included. +/// * `is_admin == false` — returns only the caller's own channels (`user_id = +/// $1`). Non-admins deliberately do not see global channels here (RBAC scope +/// preserved from the original implementation). +/// * `is_admin == true` — returns **every** channel via +/// [`list_all_notification_channels`] with owner attribution, so an admin can +/// see and manage a channel created under a different account (the engine +/// fans out to every enabled channel regardless of owner, so a foreign-owned +/// channel is a live destination the admin must be able to see). /// /// # Errors /// @@ -12352,43 +12363,65 @@ pub async fn create_notification_channel( pub async fn list_notification_channels( pool: &Pool, user_id: Uuid, - include_globals: bool, + is_admin: bool, ) -> Result> { + if is_admin { + return list_all_notification_channels(pool).await; + } let client = get_conn(pool).await?; - let rows = if include_globals { - client - .query( - &format!( - r" - SELECT {CHANNEL_COLS} - FROM notification_channels - WHERE user_id = $1 OR user_id IS NULL - ORDER BY created_at - " - ), - &[&user_id], - ) - .await - .context("list_notification_channels (with globals)")? - } else { - client - .query( - &format!( - r" - SELECT {CHANNEL_COLS} - FROM notification_channels - WHERE user_id = $1 - ORDER BY created_at - " - ), - &[&user_id], - ) - .await - .context("list_notification_channels (own)")? - }; + let rows = client + .query( + &format!( + r" + SELECT {CHANNEL_COLS} + FROM notification_channels + WHERE user_id = $1 + ORDER BY created_at + " + ), + &[&user_id], + ) + .await + .context("list_notification_channels (own)")?; Ok(rows.iter().map(notification_channel_from_row).collect()) } +/// List **all** notification channels (admin scope) with the owner's username +/// joined in for attribution. A global channel (`user_id IS NULL`) has +/// `owner_username = None`. +/// +/// # Errors +/// +/// Returns an error if the query fails. +pub async fn list_all_notification_channels(pool: &Pool) -> Result> { + let client = get_conn(pool).await?; + // Columns qualified with the `c.` alias because the join to `users` makes + // bare `id` ambiguous; `u.username` is exposed as `owner_username`. + let rows = client + .query( + r" + SELECT + c.id, c.user_id, c.kind, c.name, c.enabled, c.config, + c.camera_ids, c.include_snapshot, c.snapshot_mode, + c.created_at, c.updated_at, u.username AS owner_username + FROM notification_channels c + LEFT JOIN users u ON u.id = c.user_id + ORDER BY c.created_at + ", + &[], + ) + .await + .context("list_all_notification_channels")?; + Ok(rows + .iter() + .map(|row| { + let mut ch = notification_channel_from_row(row); + ch.owner_username = row.get("owner_username"); + ch + }) + .collect()) +} + /// Fetch a single notification channel by id. /// /// # Errors @@ -12426,6 +12459,10 @@ pub struct UpdateChannelParams { /// rule before building these params); the legacy `include_snapshot` column /// is written as its synced mirror. pub snapshot_mode: SnapshotMode, + /// Change the channel owner (global toggle), admin-only. `None` → leave + /// `user_id` unchanged; `Some(None)` → make the channel global + /// (`user_id = NULL`); `Some(Some(uid))` → claim ownership for `uid`. + pub set_owner: Option>, } /// Update a notification channel. @@ -12443,6 +12480,11 @@ pub async fn update_notification_channel( params: &UpdateChannelParams, ) -> Result> { let client = get_conn(pool).await?; + // `$9` guards the owner change: when false, `user_id` is left untouched. + // When true, `user_id` is set to `$10` (NULL for a global channel, or the + // claiming user's id). The inner Option flattens to a nullable Uuid param. + let set_owner = params.set_owner.is_some(); + let new_owner: Option = params.set_owner.flatten(); let opt = client .query_opt( &format!( @@ -12454,6 +12496,7 @@ pub async fn update_notification_channel( camera_ids = $6, include_snapshot = $7, snapshot_mode = $8, + user_id = CASE WHEN $9 THEN $10 ELSE user_id END, updated_at = now() WHERE id = $1 RETURNING {CHANNEL_COLS} @@ -12468,6 +12511,8 @@ pub async fn update_notification_channel( ¶ms.camera_ids, ¶ms.snapshot_mode.include_snapshot(), ¶ms.snapshot_mode.as_str(), + &set_owner, + &new_owner, ], ) .await