From a7a2b4efa41d3de05311491bd6edbfdc1d5266d0 Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Wed, 22 Apr 2026 01:13:11 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20large=20polish=20pass=20=E2=80=94=20UX?= =?UTF-8?q?=20fixes,=20per-chat=20scope,=20restore/backup,=20action=20even?= =?UTF-8?q?ts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend - Per-chat album scope for Immich commands (search/latest/memory/...): new allowed_album_ids on CommandTrackerListener, threaded listener/page kwargs through ProviderCommandHandler.handle; PATCH listener-scope endpoint. - /search and /find accept a trailing page number; Immich client search_smart / search_metadata take a page param. - Immich person-asset lookup switched from removed GET /api/people/{id}/assets to POST /api/search/metadata with personIds (fixes /person command and auto_organize rules silently returning zero candidates on Immich 1.106+). - Auto_organize rule now sets the target album's thumbnail to the first added image when missing (falls back to any asset type); failures do not fail the rule. add_assets_to_album surfaces the Immich error body on non-2xx. - EventLog.user_id / action_id / action_name columns with defensive migration + backfill. Status query filters by user_id directly; Immich/webhook paths emit user_id explicitly. action_runner writes an action_success/partial/ failed event on each non-dry-run. - Dashboard DELETE /api/status/events (scoped to user_id) + rendering live tracker/provider/action names via FK join with snapshot fallback. - PATCH /api/users/{id} for username/role change with last-admin guard. - Deletion protection returns structured {message, entity, blocked_by} (ApiError carries .blockedBy; frontend opens BlockedByModal). - Backup prepare-restore → AppSetting markers + atomic write of pending_restore.json; lifespan hook applies on next startup and archives under data/applied_restores/. apply-restart sends SIGTERM so the lifespan shutdown runs; NOTIFY_BRIDGE_SUPERVISED env override gates the button. Manual POST /api/backup/files (same format as scheduled). - New periodic-summary test path reuses shared collect_scheduled_assets (limit=0) so test and future production code go through one primitive. - Per-receiver locale for Telegram test messages (resolves TelegramChat.language_override per chat instead of applying the first receiver's locale to everyone). - Bounded concurrency (semaphores) in NotificationDispatcher._preload_asset_data and _refresh_telegram_chat_titles; chat title sweep extended to 24h since save_chat_from_webhook covers active chats opportunistically. - Telegram poller detects the \"webhook is active\" 409 and auto-calls deleteWebhook for bots whose DB update_mode is polling (throttled per bot). - TelegramClient.get_chat added (CLAUDE.md rule 6); set_album_thumbnail added. - Seeds: rename \"Default Commands\" → \"Default Immich Commands\"; track_assets_removed default False. Frontend - Global provider selector visible when there is only one provider. - Clear-events button + i18n + ConfirmModal on the dashboard; new icons/ labels/filters/colors for action_success / action_partial / action_failed. - Auto-select first available tracking/template/command/config + bot on create forms (trackers, command-trackers, targets, template/command configs). - Telegram target disable_url_preview defaults to true. - BlockedByModal wired into 8 deletion flows; fetchAuth helper for multipart/binary calls (reuses api()'s refresh + ApiError mapping). - Immich tracker 'Checking links' parallelised (concurrency cap 6). - Backup page: pending-restore banner + Apply-now / Apply-later modal, restarting overlay polling /api/health, manual 'Create backup' button. - Command-trackers listener row gets an 'Edit album scope' modal with inherit/explicit multiselect. - Users page: Edit user modal (username + role). - parseDate helper for consistent UTC date rendering. Migrations / schema - event_log: + user_id, action_id, action_name (+ backfill user_id from notification_tracker). - command_tracker_listener: + allowed_album_ids. --- frontend/src/lib/api.ts | 97 +++- .../src/lib/components/BlockedByModal.svelte | 74 ++++ frontend/src/lib/components/EventChart.svelte | 3 +- frontend/src/lib/grid-items.ts | 3 + frontend/src/lib/i18n/en.json | 41 +- frontend/src/lib/i18n/ru.json | 41 +- frontend/src/lib/providers/immich.ts | 18 +- frontend/src/routes/+layout.svelte | 4 +- frontend/src/routes/+page.svelte | 51 ++- .../routes/actions/ExecutionHistory.svelte | 6 +- frontend/src/routes/bots/EmailBotTab.svelte | 12 +- frontend/src/routes/bots/MatrixBotTab.svelte | 12 +- .../src/routes/bots/TelegramBotTab.svelte | 12 +- .../src/routes/command-configs/+page.svelte | 17 +- .../command-template-configs/+page.svelte | 10 +- .../src/routes/command-trackers/+page.svelte | 108 ++++- .../routes/notification-trackers/+page.svelte | 25 +- frontend/src/routes/providers/+page.svelte | 12 +- .../providers/WebhookPayloadHistory.svelte | 4 +- .../src/routes/settings/backup/+page.svelte | 188 +++++++- frontend/src/routes/targets/+page.svelte | 14 +- .../src/routes/template-configs/+page.svelte | 19 +- .../src/routes/tracking-configs/+page.svelte | 12 +- frontend/src/routes/users/+page.svelte | 49 ++- .../notifications/dispatcher.py | 104 +++++ .../notifications/telegram/cache.py | 33 +- .../notifications/telegram/client.py | 416 +++++++++++------- .../providers/immich/action_executor.py | 59 ++- .../providers/immich/asset_utils.py | 18 +- .../providers/immich/client.py | 140 ++++-- .../notify_bridge_core/templates/context.py | 10 +- .../src/notify_bridge_server/api/backup.py | 236 +++++++++- .../api/command_trackers.py | 31 ++ .../api/delete_protection.py | 18 +- .../src/notify_bridge_server/api/status.py | 108 ++++- .../api/template_configs.py | 5 +- .../src/notify_bridge_server/api/users.py | 39 ++ .../src/notify_bridge_server/api/webhooks.py | 1 + .../src/notify_bridge_server/commands/base.py | 8 +- .../commands/gitea_handler.py | 3 + .../notify_bridge_server/commands/handler.py | 23 +- .../commands/immich/handler.py | 24 +- .../commands/immich/search.py | 6 +- .../commands/nut_handler.py | 3 + .../commands/planka_handler.py | 3 + .../database/migrations.py | 23 + .../notify_bridge_server/database/models.py | 16 + .../notify_bridge_server/database/seeds.py | 10 +- .../server/src/notify_bridge_server/main.py | 3 + .../services/action_runner.py | 32 +- .../notify_bridge_server/services/notifier.py | 71 ++- .../services/pending_restore.py | 162 +++++++ .../services/sample_context.py | 6 +- .../services/scheduler.py | 136 ++++++ .../services/telegram_poller.py | 77 ++++ .../services/test_dispatch.py | 130 +++++- .../notify_bridge_server/services/watcher.py | 1 + 57 files changed, 2452 insertions(+), 335 deletions(-) create mode 100644 frontend/src/lib/components/BlockedByModal.svelte create mode 100644 packages/server/src/notify_bridge_server/services/pending_restore.py diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 9c8e8cf..802a711 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -11,6 +11,37 @@ export function errMsg(err: unknown, fallback = 'Unexpected error'): string { return fallback; } +/** Structured 409 blocked-by payload attached to ApiError.blockedBy. */ +export interface BlockedByDetail { + message: string; + entity: string; + blocked_by: string[]; +} + +export class ApiError extends Error { + status: number; + blockedBy?: BlockedByDetail; + constructor(message: string, status: number, blockedBy?: BlockedByDetail) { + super(message); + this.name = 'ApiError'; + this.status = status; + this.blockedBy = blockedBy; + } +} + +/** Parse a server-issued datetime string as UTC (appends Z if no timezone info present). */ +export function parseDate(dateStr: string): Date { + if (!dateStr) return new Date(NaN); + if (!/Z$|[+-]\d{2}:?\d{2}$/.test(dateStr)) return new Date(dateStr + 'Z'); + return new Date(dateStr); +} + +/** If the thrown error was a structured 409 from delete_protection, return its payload. */ +export function getBlockedBy(err: unknown): BlockedByDetail | null { + if (err instanceof ApiError && err.blockedBy) return err.blockedBy; + return null; +} + function getToken(): string | null { if (typeof window === 'undefined') return null; return localStorage.getItem('access_token'); @@ -106,7 +137,17 @@ export async function api( if (!res.ok) { const err = await res.json().catch(() => ({ detail: res.statusText })); - throw new Error(err.detail || `HTTP ${res.status}`); + // Structured blocked-by detail (from delete_protection.raise_if_used) + if (err && err.detail && typeof err.detail === 'object' && Array.isArray(err.detail.blocked_by)) { + const bb: BlockedByDetail = { + message: err.detail.message || `HTTP ${res.status}`, + entity: err.detail.entity || '', + blocked_by: err.detail.blocked_by, + }; + throw new ApiError(bb.message, res.status, bb); + } + const msg = typeof err.detail === 'string' ? err.detail : (err.detail?.message || `HTTP ${res.status}`); + throw new ApiError(msg, res.status); } return res.json(); @@ -114,3 +155,57 @@ export async function api( clearTimeout(timeout); } } + +/** + * Auth-aware ``fetch`` wrapper for calls that can't go through ``api()`` — + * typically multipart/form-data uploads or binary downloads where we need the + * raw ``Response`` object rather than parsed JSON. + * + * - Injects the Bearer token automatically. + * - Does NOT set ``Content-Type`` (the caller's body — e.g. ``FormData`` — + * decides the encoding; browsers add the boundary). + * - Attempts a one-shot token refresh on 401, matching ``api()``. + * - Translates non-OK responses to ``ApiError`` so callers can use the same + * ``getBlockedBy`` / ``err.message`` handling pattern. + */ +export async function fetchAuth( + path: string, + options: RequestInit = {}, +): Promise { + const token = getToken(); + const headers: Record = { ...(options.headers as Record) }; + if (token) headers['Authorization'] = `Bearer ${token}`; + + const url = path.startsWith('http') ? path : `${API_BASE}${path}`; + let res = await fetch(url, { ...options, headers }); + + if (res.status === 401 && token) { + const refreshed = await refreshAccessToken(); + if (refreshed) { + headers['Authorization'] = `Bearer ${getToken()}`; + res = await fetch(url, { ...options, headers }); + } + } + + if (res.status === 401) { + clearTokens(); + if (typeof window !== 'undefined') window.location.href = '/login'; + throw new ApiError('Unauthorized', 401); + } + + if (!res.ok) { + const err = await res.clone().json().catch(() => ({ detail: res.statusText })); + if (err && err.detail && typeof err.detail === 'object' && Array.isArray(err.detail.blocked_by)) { + const bb: BlockedByDetail = { + message: err.detail.message || `HTTP ${res.status}`, + entity: err.detail.entity || '', + blocked_by: err.detail.blocked_by, + }; + throw new ApiError(bb.message, res.status, bb); + } + const msg = typeof err.detail === 'string' ? err.detail : (err.detail?.message || `HTTP ${res.status}`); + throw new ApiError(msg, res.status); + } + + return res; +} diff --git a/frontend/src/lib/components/BlockedByModal.svelte b/frontend/src/lib/components/BlockedByModal.svelte new file mode 100644 index 0000000..3bb4f49 --- /dev/null +++ b/frontend/src/lib/components/BlockedByModal.svelte @@ -0,0 +1,74 @@ + + + + {#if detail} +
+
+ +
+
+

{detail.message}

+ {#if detail.entity} +

{detail.entity}

+ {/if} +
+
+
+

{t('common.blockedByIntro')}

+ {#if blockedCount > 0} + + {blockedCount} + + {/if} +
+ {#if blockedCount > 0} +
    + {#each detail.blocked_by as consumer} +
  • + + + + {consumer} +
  • + {/each} +
+ {/if} + {/if} +
+ +
+
+ + diff --git a/frontend/src/lib/components/EventChart.svelte b/frontend/src/lib/components/EventChart.svelte index 841b4c6..9ce9e9f 100644 --- a/frontend/src/lib/components/EventChart.svelte +++ b/frontend/src/lib/components/EventChart.svelte @@ -1,5 +1,6 @@ @@ -252,13 +274,23 @@ -

- - {t('dashboard.recentEvents')} +
+

+ + {t('dashboard.recentEvents')} + {#if status.total_events > 0} + ({status.total_events}) + {/if} +

{#if status.total_events > 0} - ({status.total_events}) + {/if} -

+
@@ -370,6 +402,9 @@ {/if} {/if} + confirmClearEvents = false} /> + diff --git a/frontend/src/routes/actions/ExecutionHistory.svelte b/frontend/src/routes/actions/ExecutionHistory.svelte index 06e3e91..fa144e2 100644 --- a/frontend/src/routes/actions/ExecutionHistory.svelte +++ b/frontend/src/routes/actions/ExecutionHistory.svelte @@ -1,5 +1,5 @@ @@ -280,6 +286,8 @@ confirmDelete = null} /> + blockedBy = null} /> + diff --git a/frontend/src/routes/targets/+page.svelte b/frontend/src/routes/targets/+page.svelte index e9ed685..43f8f7c 100644 --- a/frontend/src/routes/targets/+page.svelte +++ b/frontend/src/routes/targets/+page.svelte @@ -1,7 +1,8 @@